mirror of
https://github.com/versity/versitygw.git
synced 2026-01-24 20:12:01 +00:00
Compare commits
358 Commits
ben/plugin
...
v1.0.18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c359e9f0 | ||
|
|
6d081f5a3f | ||
|
|
7797154812 | ||
|
|
eb0a8ee0c0 | ||
|
|
31799f48c8 | ||
|
|
34da18337e | ||
|
|
148836bb0c | ||
|
|
18e30127d5 | ||
|
|
6c0b8ea019 | ||
|
|
8e18b43116 | ||
|
|
406161ba10 | ||
|
|
dd91cecd00 | ||
|
|
2bb8a1eeb7 | ||
|
|
3375689010 | ||
|
|
c206f6414e | ||
|
|
7a098b925f | ||
|
|
8fb020ef83 | ||
|
|
d7c73a06ea | ||
|
|
04fbe405ca | ||
|
|
818e91ebde | ||
|
|
743707b9ae | ||
|
|
dd151001a2 | ||
|
|
f50e008ceb | ||
|
|
488a9ac1bb | ||
|
|
b46a486d29 | ||
|
|
5aa407d832 | ||
|
|
b358e385db | ||
|
|
24b1c45db3 | ||
|
|
cae6f3d1fe | ||
|
|
b3ed7639f0 | ||
|
|
e2fb272711 | ||
|
|
a4091fd61c | ||
|
|
0bf49872cf | ||
|
|
39de3b9f9a | ||
|
|
8cad7fd6d9 | ||
|
|
58117c011a | ||
|
|
2015cc1ab2 | ||
|
|
fbde51b3ea | ||
|
|
5ea9c6e956 | ||
|
|
278946f132 | ||
|
|
c93d2cd1f2 | ||
|
|
13ea2286f7 | ||
|
|
4c41b8be3b | ||
|
|
e7efc1deb9 | ||
|
|
843620235b | ||
|
|
2a4d86d8d0 | ||
|
|
6a82213606 | ||
|
|
45a4d1892f | ||
|
|
a06a1f007a | ||
|
|
3d20a63f75 | ||
|
|
1eeb7de0b6 | ||
|
|
ee1cbeac15 | ||
|
|
f29337aae6 | ||
|
|
9772badd43 | ||
|
|
c82582bf07 | ||
|
|
630651254f | ||
|
|
5d2a1527e0 | ||
|
|
d90944afd1 | ||
|
|
ac4229cd6d | ||
|
|
b3286c44e2 | ||
|
|
9992e341da | ||
|
|
8210dc4cbb | ||
|
|
14a2984d59 | ||
|
|
0895ada9ed | ||
|
|
867cdb5f97 | ||
|
|
2ae66311a7 | ||
|
|
d784c0a841 | ||
|
|
be79fc249d | ||
|
|
3a51b1ee5c | ||
|
|
7954d386b2 | ||
|
|
88f84bfd89 | ||
|
|
45a1f7ae7c | ||
|
|
be1708b1df | ||
|
|
617ad0fd31 | ||
|
|
3e4c31f14a | ||
|
|
502a72bf20 | ||
|
|
6b450a5c11 | ||
|
|
5f28a7449e | ||
|
|
025b0ee3c8 | ||
|
|
09031a30e5 | ||
|
|
5fb73deef1 | ||
|
|
dafe099d9b | ||
|
|
795324109e | ||
|
|
794d01a0ae | ||
|
|
020542639a | ||
|
|
3703d919f6 | ||
|
|
56af16fcc4 | ||
|
|
ec80b11cef | ||
|
|
12ab923a35 | ||
|
|
24b88e20e0 | ||
|
|
cdccdcc4d6 | ||
|
|
ed92ad3daa | ||
|
|
2679ac70b6 | ||
|
|
3208247597 | ||
|
|
8db196634b | ||
|
|
f31a56316b | ||
|
|
0eadc3871e | ||
|
|
84a989a23c | ||
|
|
6be62f189d | ||
|
|
36d2a55162 | ||
|
|
15f19cc75c | ||
|
|
634396c3c5 | ||
|
|
e134f63ebc | ||
|
|
01760fdf1c | ||
|
|
cef2950a79 | ||
|
|
b0054fc415 | ||
|
|
f0858a47d5 | ||
|
|
298d4ec6b4 | ||
|
|
3934beae2f | ||
|
|
ba017420c4 | ||
|
|
936239b619 | ||
|
|
e62337f055 | ||
|
|
0be8b2aedd | ||
|
|
9122f66438 | ||
|
|
47e49ce593 | ||
|
|
38e43eedfb | ||
|
|
8e6dd45ce5 | ||
|
|
742cebb5e5 | ||
|
|
26a8502f29 | ||
|
|
501d57cbb0 | ||
|
|
46650314af | ||
|
|
13c7cb488c | ||
|
|
19206b0da2 | ||
|
|
16484083ad | ||
|
|
0972af0783 | ||
|
|
69ba00a25f | ||
|
|
3842253962 | ||
|
|
fb372e497d | ||
|
|
e18c4f4080 | ||
|
|
7dc213e68e | ||
|
|
bcbe739158 | ||
|
|
c63c0a7a24 | ||
|
|
3363988206 | ||
|
|
4187b4d400 | ||
|
|
35fc8c214a | ||
|
|
2b9e343132 | ||
|
|
70be7d7363 | ||
|
|
9d129aaa26 | ||
|
|
4395c9e0f9 | ||
|
|
891672bf7e | ||
|
|
1fb3a7d466 | ||
|
|
e5850ff11f | ||
|
|
ccb4895367 | ||
|
|
e74d2c0d19 | ||
|
|
dc16c0448f | ||
|
|
394675a5a8 | ||
|
|
ab571a6571 | ||
|
|
7f9ab35347 | ||
|
|
ba76aea17a | ||
|
|
67d0750ee0 | ||
|
|
866b07b98f | ||
|
|
65cd44aadd | ||
|
|
5be9e3bd1e | ||
|
|
abdf342ef7 | ||
|
|
b7c758b065 | ||
|
|
a3fef4254a | ||
|
|
56d4e4aa3e | ||
|
|
d2038ca973 | ||
|
|
a7c3cb5cf8 | ||
|
|
b8456bc5ab | ||
|
|
f877502ab0 | ||
|
|
edaf9d6d4e | ||
|
|
39cef57c87 | ||
|
|
4f3c930d52 | ||
|
|
ddbc8911aa | ||
|
|
6e91e874c8 | ||
|
|
70c25de544 | ||
|
|
b2516e4153 | ||
|
|
08ccf821f9 | ||
|
|
b57be7d56f | ||
|
|
e39ab6f0ee | ||
|
|
4eb13c2fdc | ||
|
|
0c2252fde0 | ||
|
|
a915c3fec4 | ||
|
|
706dee8572 | ||
|
|
c6944650a3 | ||
|
|
c3201081ce | ||
|
|
9cc29af073 | ||
|
|
7d98d1df39 | ||
|
|
f295df2217 | ||
|
|
cbd3eb1cd2 | ||
|
|
c196b5f999 | ||
|
|
839909c880 | ||
|
|
68c002486d | ||
|
|
4117bcdf65 | ||
|
|
003bf5db0b | ||
|
|
91b904d10f | ||
|
|
ee4d0b0c3e | ||
|
|
78a92168bf | ||
|
|
36509daec7 | ||
|
|
28cb97329e | ||
|
|
3ec6e634c3 | ||
|
|
7b8b483dfc | ||
|
|
4ce0ba33e9 | ||
|
|
98a7b7f402 | ||
|
|
b09efa532c | ||
|
|
1066c44a04 | ||
|
|
0d73e3ebe2 | ||
|
|
5ba5327ba6 | ||
|
|
78537bedf9 | ||
|
|
c276e0ebe4 | ||
|
|
1c08eaadcd | ||
|
|
458db64e2d | ||
|
|
8e5b7ead92 | ||
|
|
868c17e590 | ||
|
|
f9b73208ef | ||
|
|
7260854cd0 | ||
|
|
58659ae279 | ||
|
|
532123e84d | ||
|
|
c4cfc8a08a | ||
|
|
d9300eaa6e | ||
|
|
580b07c24b | ||
|
|
c35c73fa72 | ||
|
|
3aa2042a79 | ||
|
|
5e3d4cbeec | ||
|
|
729321e1e8 | ||
|
|
b99d7e29ae | ||
|
|
23007f4198 | ||
|
|
71333b2709 | ||
|
|
22e29b84a3 | ||
|
|
d831985f13 | ||
|
|
6541232a2d | ||
|
|
082498a65c | ||
|
|
2d2bb1aa5c | ||
|
|
b33499c453 | ||
|
|
97dd0a92bc | ||
|
|
7e5695f63b | ||
|
|
f630bf3c9e | ||
|
|
0b004ff4a8 | ||
|
|
d971e0e988 | ||
|
|
d776537944 | ||
|
|
18bcfebbab | ||
|
|
23cebcee2c | ||
|
|
282e875d9f | ||
|
|
f912778617 | ||
|
|
23169fa51d | ||
|
|
cd45a24100 | ||
|
|
c632e647f3 | ||
|
|
9a2acceaa8 | ||
|
|
276ea75de5 | ||
|
|
bbb62927a5 | ||
|
|
60aaaa0908 | ||
|
|
e2905b6880 | ||
|
|
87ece0cc26 | ||
|
|
4405fb1d26 | ||
|
|
b9b75b58f6 | ||
|
|
4f8b1ffb1c | ||
|
|
46bde72474 | ||
|
|
14d2b8a0ed | ||
|
|
dbc710da2d | ||
|
|
ed125c317e | ||
|
|
32c6f2e463 | ||
|
|
845fe73b20 | ||
|
|
925f89465e | ||
|
|
12b25b7f83 | ||
|
|
68d267e422 | ||
|
|
ae7d5f677f | ||
|
|
35cdef1eba | ||
|
|
85b6437a28 | ||
|
|
132086d9d5 | ||
|
|
4334f869f2 | ||
|
|
9ef7ee8254 | ||
|
|
6b20ec96f4 | ||
|
|
8bd5831182 | ||
|
|
720a7e5628 | ||
|
|
3e50e29306 | ||
|
|
1e91d901e7 | ||
|
|
afbcbcac13 | ||
|
|
8e2d51e501 | ||
|
|
1f5f040840 | ||
|
|
d3bcd8ffc5 | ||
|
|
4c5f65da96 | ||
|
|
323717bcf1 | ||
|
|
be275bbb2c | ||
|
|
a022c3bdb6 | ||
|
|
d3585e6c1c | ||
|
|
42b03b866c | ||
|
|
3740d79173 | ||
|
|
f4577d4af5 | ||
|
|
809d969afb | ||
|
|
3a9f8c6525 | ||
|
|
23b5e60854 | ||
|
|
2d5d641824 | ||
|
|
4478ed1143 | ||
|
|
22703de0c8 | ||
|
|
5122b8c6ed | ||
|
|
42013d365b | ||
|
|
a77c24f61f | ||
|
|
e7294c631f | ||
|
|
c3334008f5 | ||
|
|
b12b0d242e | ||
|
|
384bb463d3 | ||
|
|
4b34ef1a5f | ||
|
|
e0999ce5a3 | ||
|
|
dfa1ed2358 | ||
|
|
98867bc731 | ||
|
|
e98f7763d0 | ||
|
|
e9286f7a23 | ||
|
|
a60d6a7faa | ||
|
|
a29f7b1839 | ||
|
|
6321406008 | ||
|
|
cd9cb108a3 | ||
|
|
78910fb556 | ||
|
|
a9fcf63063 | ||
|
|
1ef81d985e | ||
|
|
d19c446f72 | ||
|
|
2e7a7fcbe9 | ||
|
|
c45b32066f | ||
|
|
9f13b544f7 | ||
|
|
1f96af5c66 | ||
|
|
ddceb28f98 | ||
|
|
c497baa733 | ||
|
|
9244e9100d | ||
|
|
4eba4e031c | ||
|
|
32faf9a4c3 | ||
|
|
a4d2f5c180 | ||
|
|
24fbbdbd63 | ||
|
|
2b1e1af89b | ||
|
|
dea4b6382f | ||
|
|
8c101b3901 | ||
|
|
7f9b9dfd97 | ||
|
|
224ab5111f | ||
|
|
b69352bdd6 | ||
|
|
aecea5f068 | ||
|
|
5e6056467e | ||
|
|
9bd3c21606 | ||
|
|
e1e54b1175 | ||
|
|
9f788c4266 | ||
|
|
9082d469e7 | ||
|
|
1ea2e42f0a | ||
|
|
63fd82654d | ||
|
|
6fa58db6ab | ||
|
|
b4486b095d | ||
|
|
f831578d51 | ||
|
|
c7c454ddcc | ||
|
|
fe29e826f3 | ||
|
|
749eebc97f | ||
|
|
a31fd1a9c3 | ||
|
|
999f360139 | ||
|
|
1ba370421b | ||
|
|
1f8a7d11eb | ||
|
|
d63ae74faa | ||
|
|
38f532e6e7 | ||
|
|
520122bec5 | ||
|
|
bd986e97f3 | ||
|
|
bbb5a22c89 | ||
|
|
7ef2bb4547 | ||
|
|
f0a1184459 | ||
|
|
f677500caf | ||
|
|
adadba8fa8 | ||
|
|
768bc8767c | ||
|
|
fe2b66b26d | ||
|
|
4690afb5a0 | ||
|
|
3e6bd89fa2 | ||
|
|
f630392718 | ||
|
|
df6dcff429 | ||
|
|
5d7a68cabd | ||
|
|
66b979ee86 |
25
.github/SECURITY.md
vendored
Normal file
25
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in `versitygw`, we strongly encourage you to report it privately and responsibly.
|
||||
|
||||
Please do **not** create public issues or pull requests that contain details about the vulnerability.
|
||||
|
||||
Instead, report the issue using GitHub's private **Security Advisories** feature:
|
||||
|
||||
- Go to [versitygw's Security Advisories page](https://github.com/versity/versitygw/security/advisories)
|
||||
- Click on **"Report a vulnerability"**
|
||||
|
||||
We aim to respond within **2 business days** and work with you to quickly resolve the issue.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| --------------- | --------- |
|
||||
| Latest (v1.x.x) | ✅ |
|
||||
| Older versions | ❌ |
|
||||
|
||||
## Responsible Disclosure
|
||||
|
||||
We appreciate responsible disclosures and are committed to fixing vulnerabilities in a timely manner. Thank you for helping keep `versitygw` secure.
|
||||
2
.github/workflows/azurite.yml
vendored
2
.github/workflows/azurite.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: azurite functional tests
|
||||
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/docker-bats.yml
vendored
2
.github/workflows/docker-bats.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: docker bats tests
|
||||
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
|
||||
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -1,5 +1,4 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
2
.github/workflows/functional.yml
vendored
2
.github/workflows/functional.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: functional tests
|
||||
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
|
||||
30
.github/workflows/go.yml
vendored
30
.github/workflows/go.yml
vendored
@@ -1,9 +1,10 @@
|
||||
name: general
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
name: Go Basic Checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -23,9 +24,6 @@ jobs:
|
||||
run: |
|
||||
go get -v -t -d ./...
|
||||
|
||||
- name: Build
|
||||
run: make
|
||||
|
||||
- name: Test
|
||||
run: go test -coverprofile profile.txt -race -v -timeout 30s -tags=github ./...
|
||||
|
||||
@@ -35,4 +33,26 @@ jobs:
|
||||
|
||||
- name: Run govulncheck
|
||||
run: govulncheck ./...
|
||||
shell: bash
|
||||
shell: bash
|
||||
|
||||
verify-build:
|
||||
name: Verify Build Targets
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [darwin, freebsd, linux]
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
|
||||
- name: Build for ${{ matrix.os }}/${{ matrix.arch }}
|
||||
run: |
|
||||
GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -o versitygw-${{ matrix.os }}-${{ matrix.arch }} cmd/versitygw/*.go
|
||||
12
.github/workflows/goreleaser.yml
vendored
12
.github/workflows/goreleaser.yml
vendored
@@ -1,16 +1,12 @@
|
||||
name: goreleaser
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
on:
|
||||
push:
|
||||
# run only against tags
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# packages: write
|
||||
# issues: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -29,10 +25,10 @@ jobs:
|
||||
go-version: stable
|
||||
|
||||
- name: Run Releaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.TOKEN }}
|
||||
|
||||
13
.github/workflows/host-style-tests.yml
vendored
Normal file
13
.github/workflows/host-style-tests.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: host style tests
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
build-and-run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: run host-style tests
|
||||
run: make test-host-style
|
||||
1
.github/workflows/shellcheck.yml
vendored
1
.github/workflows/shellcheck.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: shellcheck
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
jobs:
|
||||
|
||||
|
||||
1
.github/workflows/static.yml
vendored
1
.github/workflows/static.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: staticcheck
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
jobs:
|
||||
|
||||
|
||||
44
.github/workflows/system.yml
vendored
44
.github/workflows/system.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: system tests
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
jobs:
|
||||
build:
|
||||
@@ -12,66 +13,91 @@ jobs:
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "mc-non-file-count"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "mc, posix, file count, non-static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "mc-file-count"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "REST, posix, non-static, all, folder IAM"
|
||||
- set: "REST, posix, non-static, base|acl|multipart|put-object, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "rest"
|
||||
RUN_SET: "rest-base,rest-acl,rest-multipart,rest-put-object"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "REST, posix, non-static, chunked|checksum|versioning|bucket, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "rest-chunked,rest-checksum,rest-versioning,rest-bucket,rest-list-buckets,rest-create-bucket,rest-head-bucket"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "REST, posix, non-static, not implemented|rest-delete-bucket-ownership-controls|rest-delete-bucket-tagging, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "rest-not-implemented,rest-delete-bucket-ownership-controls,rest-delete-bucket-tagging"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "s3, posix, non-file count, non-static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3-non-file-count"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "s3, posix, file count, non-static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3-file-count"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "s3api, posix, bucket|object|multipart, non-static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3api-bucket,s3api-object,s3api-multipart"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "s3api, posix, policy, non-static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3api-policy"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "s3api, posix, user, non-static, s3 IAM"
|
||||
IAM_TYPE: s3
|
||||
RUN_SET: "s3api-user"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "s3api, posix, bucket, static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3api-bucket"
|
||||
RECREATE_BUCKETS: "false"
|
||||
DELETE_BUCKETS_AFTER_TEST: "false"
|
||||
BACKEND: "posix"
|
||||
- set: "s3api, posix, multipart, static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3api-multipart"
|
||||
RECREATE_BUCKETS: "false"
|
||||
DELETE_BUCKETS_AFTER_TEST: "false"
|
||||
BACKEND: "posix"
|
||||
- set: "s3api, posix, object, static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3api-object"
|
||||
RECREATE_BUCKETS: "false"
|
||||
DELETE_BUCKETS_AFTER_TEST: "false"
|
||||
BACKEND: "posix"
|
||||
- set: "s3api, posix, policy, static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3api-policy"
|
||||
RECREATE_BUCKETS: "false"
|
||||
DELETE_BUCKETS_AFTER_TEST: "false"
|
||||
BACKEND: "posix"
|
||||
- set: "s3api, posix, user, static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3api-user"
|
||||
RECREATE_BUCKETS: "false"
|
||||
DELETE_BUCKETS_AFTER_TEST: "false"
|
||||
BACKEND: "posix"
|
||||
# TODO fix/debug s3 gateway
|
||||
#- set: "s3api, s3, multipart|object, non-static, folder IAM"
|
||||
@@ -88,16 +114,19 @@ jobs:
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3cmd-file-count"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "s3cmd, posix, non-user, non-static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3cmd-non-user"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
- set: "s3cmd, posix, user, non-static, folder IAM"
|
||||
IAM_TYPE: folder
|
||||
RUN_SET: "s3cmd-user"
|
||||
RECREATE_BUCKETS: "true"
|
||||
DELETE_BUCKETS_AFTER_TEST: "true"
|
||||
BACKEND: "posix"
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
@@ -106,7 +135,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
go-version: "stable"
|
||||
id: go
|
||||
|
||||
- name: Get Dependencies
|
||||
@@ -122,6 +151,7 @@ jobs:
|
||||
|
||||
- name: Install s3cmd
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install s3cmd
|
||||
|
||||
- name: Install mc
|
||||
@@ -129,9 +159,10 @@ jobs:
|
||||
curl https://dl.min.io/client/mc/release/linux-amd64/mc --create-dirs -o /usr/local/bin/mc
|
||||
chmod 755 /usr/local/bin/mc
|
||||
|
||||
- name: Install xmllint (for rest)
|
||||
- name: Install xml libraries (for rest)
|
||||
run: |
|
||||
sudo apt-get install libxml2-utils
|
||||
sudo apt-get update
|
||||
sudo apt-get install libxml2-utils xmlstarlet
|
||||
|
||||
# see https://github.com/versity/versitygw/issues/1034
|
||||
- name: Install AWS cli
|
||||
@@ -150,6 +181,7 @@ jobs:
|
||||
RUN_VERSITYGW: true
|
||||
BACKEND: ${{ matrix.BACKEND }}
|
||||
RECREATE_BUCKETS: ${{ matrix.RECREATE_BUCKETS }}
|
||||
DELETE_BUCKETS_AFTER_TEST: ${{ matrix.DELETE_BUCKETS_AFTER_TEST }}
|
||||
CERT: ${{ github.workspace }}/cert.pem
|
||||
KEY: ${{ github.workspace }}/versitygw.pem
|
||||
LOCAL_FOLDER: /tmp/gw
|
||||
@@ -173,6 +205,8 @@ jobs:
|
||||
COMMAND_LOG: command.log
|
||||
TIME_LOG: time.log
|
||||
PYTHON_ENV_FOLDER: ${{ github.workspace }}/env
|
||||
AUTOGENERATE_USERS: true
|
||||
USER_AUTOGENERATION_PREFIX: github-actions-test-
|
||||
run: |
|
||||
make testbin
|
||||
export AWS_ACCESS_KEY_ID=ABCDEFGHIJKLMNOPQRST
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
@@ -23,7 +25,7 @@ builds:
|
||||
- -X=main.Build={{.Commit}} -X=main.BuildTime={{.Date}} -X=main.Version={{.Version}}
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
- formats: [ 'tar.gz' ]
|
||||
# this name template makes the OS and Arch compatible with the results of uname.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_v{{ .Version }}_
|
||||
@@ -43,7 +45,7 @@ archives:
|
||||
# use zip for windows archives
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats: [ 'zip' ]
|
||||
|
||||
# Additional files/globs you want to add to the archive.
|
||||
#
|
||||
@@ -58,7 +60,7 @@ checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
version_template: "{{ incpatch .Version }}-{{.ShortCommit}}"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
@@ -86,7 +88,7 @@ nfpms:
|
||||
|
||||
license: Apache 2.0
|
||||
|
||||
builds:
|
||||
ids:
|
||||
- versitygw
|
||||
|
||||
formats:
|
||||
|
||||
11
Makefile
11
Makefile
@@ -72,6 +72,11 @@ dist:
|
||||
rm -f VERSION
|
||||
gzip -f $(TARFILE)
|
||||
|
||||
.PHONY: snapshot
|
||||
snapshot:
|
||||
# brew install goreleaser/tap/goreleaser
|
||||
goreleaser release --snapshot --skip publish --clean
|
||||
|
||||
# Creates and runs S3 gateway instance in a docker container
|
||||
.PHONY: up-posix
|
||||
up-posix:
|
||||
@@ -91,3 +96,9 @@ up-azurite:
|
||||
.PHONY: up-app
|
||||
up-app:
|
||||
$(DOCKERCOMPOSE) up
|
||||
|
||||
# Run the host-style tests in docker containers
|
||||
.PHONY: test-host-style
|
||||
test-host-style:
|
||||
docker compose -f tests/host-style-tests/docker-compose.yml up --build --abort-on-container-exit --exit-code-from test
|
||||
|
||||
|
||||
189
auth/access-control.go
Normal file
189
auth/access-control.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func VerifyObjectCopyAccess(ctx context.Context, be backend.Backend, copySource string, opts AccessOptions) error {
|
||||
if opts.IsRoot {
|
||||
return nil
|
||||
}
|
||||
if opts.Acc.Role == RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify destination bucket access
|
||||
if err := VerifyAccess(ctx, be, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
// Verify source bucket access
|
||||
srcBucket, srcObject, found := strings.Cut(copySource, "/")
|
||||
if !found {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidCopySourceBucket)
|
||||
}
|
||||
|
||||
// Get source bucket ACL
|
||||
srcBucketACLBytes, err := be.GetBucketAcl(ctx, &s3.GetBucketAclInput{Bucket: &srcBucket})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var srcBucketAcl ACL
|
||||
if err := json.Unmarshal(srcBucketACLBytes, &srcBucketAcl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := VerifyAccess(ctx, be, AccessOptions{
|
||||
Acl: srcBucketAcl,
|
||||
AclPermission: PermissionRead,
|
||||
IsRoot: opts.IsRoot,
|
||||
Acc: opts.Acc,
|
||||
Bucket: srcBucket,
|
||||
Object: srcObject,
|
||||
Action: GetObjectAction,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type AccessOptions struct {
|
||||
Acl ACL
|
||||
AclPermission Permission
|
||||
IsRoot bool
|
||||
Acc Account
|
||||
Bucket string
|
||||
Object string
|
||||
Action Action
|
||||
Readonly bool
|
||||
IsPublicRequest bool
|
||||
}
|
||||
|
||||
func VerifyAccess(ctx context.Context, be backend.Backend, opts AccessOptions) error {
|
||||
// Skip the access check for public bucket requests
|
||||
if opts.IsPublicRequest {
|
||||
return nil
|
||||
}
|
||||
if opts.Readonly {
|
||||
if opts.AclPermission == PermissionWrite || opts.AclPermission == PermissionWriteAcp {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
}
|
||||
if opts.IsRoot {
|
||||
return nil
|
||||
}
|
||||
if opts.Acc.Role == RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
policy, policyErr := be.GetBucketPolicy(ctx, opts.Bucket)
|
||||
if policyErr != nil {
|
||||
if !errors.Is(policyErr, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) {
|
||||
return policyErr
|
||||
}
|
||||
} else {
|
||||
return VerifyBucketPolicy(policy, opts.Acc.Access, opts.Bucket, opts.Object, opts.Action)
|
||||
}
|
||||
|
||||
if err := verifyACL(opts.Acl, opts.Acc.Access, opts.AclPermission); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Detects if the action is policy related
|
||||
// e.g.
|
||||
// 'GetBucketPolicy', 'PutBucketPolicy'
|
||||
func isPolicyAction(action Action) bool {
|
||||
return action == GetBucketPolicyAction || action == PutBucketPolicyAction
|
||||
}
|
||||
|
||||
// VerifyPublicAccess checks if the bucket is publically accessible by ACL or Policy
|
||||
func VerifyPublicAccess(ctx context.Context, be backend.Backend, action Action, permission Permission, bucket, object string) error {
|
||||
// ACL disabled
|
||||
policy, err := be.GetBucketPolicy(ctx, bucket)
|
||||
if err != nil && !errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
err = VerifyPublicBucketPolicy(policy, bucket, object, action)
|
||||
if err == nil {
|
||||
// if ACLs are disabled, and the bucket grants public access,
|
||||
// policy actions should return 'MethodNotAllowed'
|
||||
if isPolicyAction(action) {
|
||||
return s3err.GetAPIError(s3err.ErrMethodNotAllowed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// if the action is not in the ACL whitelist the access is denied
|
||||
_, ok := publicACLAllowedActions[action]
|
||||
if !ok {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
err = VerifyPublicBucketACL(ctx, be, bucket, action, permission)
|
||||
if err != nil {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAdminOrOwner(acct Account, isRoot bool, acl ACL) error {
|
||||
// Owner check
|
||||
if acct.Access == acl.Owner {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Root user has access over almost everything
|
||||
if isRoot {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Admin user case
|
||||
if acct.Role == RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return access denied in all other cases
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
type PublicACLAllowedActions map[Action]struct{}
|
||||
|
||||
var publicACLAllowedActions PublicACLAllowedActions = PublicACLAllowedActions{
|
||||
ListBucketAction: struct{}{},
|
||||
PutObjectAction: struct{}{},
|
||||
ListBucketMultipartUploadsAction: struct{}{},
|
||||
DeleteObjectAction: struct{}{},
|
||||
ListBucketVersionsAction: struct{}{},
|
||||
GetObjectAction: struct{}{},
|
||||
GetObjectAttributesAction: struct{}{},
|
||||
GetObjectAclAction: struct{}{},
|
||||
}
|
||||
153
auth/acl.go
153
auth/acl.go
@@ -33,6 +33,17 @@ type ACL struct {
|
||||
Grantees []Grantee
|
||||
}
|
||||
|
||||
// IsPublic specifies if the acl grants public read access
|
||||
func (acl *ACL) IsPublic(permission Permission) bool {
|
||||
for _, grt := range acl.Grantees {
|
||||
if grt.Permission == permission && grt.Type == types.TypeGroup && grt.Access == "all-users" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type Grantee struct {
|
||||
Permission Permission
|
||||
Access string
|
||||
@@ -374,7 +385,7 @@ func CheckIfAccountsExist(accs []string, iam IAMService) ([]string, error) {
|
||||
for _, acc := range accs {
|
||||
_, err := iam.GetUserAccount(acc)
|
||||
if err != nil {
|
||||
if err == ErrNoSuchUser {
|
||||
if err == ErrNoSuchUser || err == s3err.GetAPIError(s3err.ErrAdminUserNotFound) {
|
||||
result = append(result, acc)
|
||||
continue
|
||||
}
|
||||
@@ -435,118 +446,50 @@ func verifyACL(acl ACL, access string, permission Permission) error {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
func MayCreateBucket(acct Account, isRoot bool) error {
|
||||
if isRoot {
|
||||
return nil
|
||||
}
|
||||
|
||||
if acct.Role == RoleUser {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAdminOrOwner(acct Account, isRoot bool, acl ACL) error {
|
||||
// Owner check
|
||||
if acct.Access == acl.Owner {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Root user has access over almost everything
|
||||
if isRoot {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Admin user case
|
||||
if acct.Role == RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return access denied in all other cases
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
type AccessOptions struct {
|
||||
Acl ACL
|
||||
AclPermission Permission
|
||||
IsRoot bool
|
||||
Acc Account
|
||||
Bucket string
|
||||
Object string
|
||||
Action Action
|
||||
Readonly bool
|
||||
}
|
||||
|
||||
func VerifyAccess(ctx context.Context, be backend.Backend, opts AccessOptions) error {
|
||||
if opts.Readonly {
|
||||
if opts.AclPermission == PermissionWrite || opts.AclPermission == PermissionWriteAcp {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
}
|
||||
if opts.IsRoot {
|
||||
return nil
|
||||
}
|
||||
if opts.Acc.Role == RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
policy, policyErr := be.GetBucketPolicy(ctx, opts.Bucket)
|
||||
if policyErr != nil {
|
||||
if !errors.Is(policyErr, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) {
|
||||
return policyErr
|
||||
}
|
||||
} else {
|
||||
return VerifyBucketPolicy(policy, opts.Acc.Access, opts.Bucket, opts.Object, opts.Action)
|
||||
}
|
||||
|
||||
if err := verifyACL(opts.Acl, opts.Acc.Access, opts.AclPermission); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func VerifyObjectCopyAccess(ctx context.Context, be backend.Backend, copySource string, opts AccessOptions) error {
|
||||
if opts.IsRoot {
|
||||
return nil
|
||||
}
|
||||
if opts.Acc.Role == RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify destination bucket access
|
||||
if err := VerifyAccess(ctx, be, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
// Verify source bucket access
|
||||
srcBucket, srcObject, found := strings.Cut(copySource, "/")
|
||||
if !found {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidCopySource)
|
||||
}
|
||||
|
||||
// Get source bucket ACL
|
||||
srcBucketACLBytes, err := be.GetBucketAcl(ctx, &s3.GetBucketAclInput{Bucket: &srcBucket})
|
||||
// Verifies if the bucket acl grants public access
|
||||
func VerifyPublicBucketACL(ctx context.Context, be backend.Backend, bucket string, action Action, permission Permission) error {
|
||||
aclBytes, err := be.GetBucketAcl(ctx, &s3.GetBucketAclInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var srcBucketAcl ACL
|
||||
if err := json.Unmarshal(srcBucketACLBytes, &srcBucketAcl); err != nil {
|
||||
acl, err := ParseACL(aclBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := VerifyAccess(ctx, be, AccessOptions{
|
||||
Acl: srcBucketAcl,
|
||||
AclPermission: PermissionRead,
|
||||
IsRoot: opts.IsRoot,
|
||||
Acc: opts.Acc,
|
||||
Bucket: srcBucket,
|
||||
Object: srcObject,
|
||||
Action: GetObjectAction,
|
||||
}); err != nil {
|
||||
return err
|
||||
if !acl.IsPublic(permission) {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBucketACLOwner sets default ACL with new owner and removes
|
||||
// any previous bucket policy that was in place
|
||||
func UpdateBucketACLOwner(ctx context.Context, be backend.Backend, bucket, newOwner string) error {
|
||||
acl := ACL{
|
||||
Owner: newOwner,
|
||||
Grantees: []Grantee{
|
||||
{
|
||||
Permission: PermissionFullControl,
|
||||
Access: newOwner,
|
||||
Type: types.TypeCanonicalUser,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := json.Marshal(acl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ACL: %w", err)
|
||||
}
|
||||
|
||||
err = be.PutBucketAcl(ctx, bucket, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return be.DeleteBucketPolicy(ctx, bucket)
|
||||
}
|
||||
|
||||
338
auth/bucket_cors.go
Normal file
338
auth/bucket_cors.go
Normal file
@@ -0,0 +1,338 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
// headerRegex is the regexp to validate http header names
|
||||
var headerRegex = regexp.MustCompile(`^[!#$%&'*+\-.^_` + "`" + `|~0-9A-Za-z]+$`)
|
||||
|
||||
type CORSHeader string
|
||||
type CORSHTTPMethod string
|
||||
|
||||
// IsValid validates the CORS http header
|
||||
// the rules are based on http RFC
|
||||
// https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
|
||||
//
|
||||
// Empty values are considered as valid
|
||||
func (ch CORSHeader) IsValid() bool {
|
||||
return ch == "" || headerRegex.MatchString(ch.String())
|
||||
}
|
||||
|
||||
// String converts the header value to 'string'
|
||||
func (ch CORSHeader) String() string {
|
||||
return string(ch)
|
||||
}
|
||||
|
||||
// ToLower converts the header to lower case
|
||||
func (ch CORSHeader) ToLower() string {
|
||||
return strings.ToLower(string(ch))
|
||||
}
|
||||
|
||||
// IsValid validates the cors http request method:
|
||||
// the methods are case sensitive
|
||||
func (cm CORSHTTPMethod) IsValid() bool {
|
||||
return cm.IsEmpty() || cm == http.MethodGet || cm == http.MethodHead || cm == http.MethodPut ||
|
||||
cm == http.MethodPost || cm == http.MethodDelete
|
||||
}
|
||||
|
||||
// IsEmpty checks if the cors method is an empty string
|
||||
func (cm CORSHTTPMethod) IsEmpty() bool {
|
||||
return cm == ""
|
||||
}
|
||||
|
||||
// String converts the method value to 'string'
|
||||
func (cm CORSHTTPMethod) String() string {
|
||||
return string(cm)
|
||||
}
|
||||
|
||||
type CORSConfiguration struct {
|
||||
Rules []CORSRule `xml:"CORSRule"`
|
||||
}
|
||||
|
||||
// Validate validates the cors configuration rules
|
||||
func (cc *CORSConfiguration) Validate() error {
|
||||
if cc == nil || cc.Rules == nil {
|
||||
debuglogger.Logf("invalid CORS configuration")
|
||||
return s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
if len(cc.Rules) == 0 {
|
||||
debuglogger.Logf("empty CORS config rules")
|
||||
return s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
// validate each CORS rule
|
||||
for _, rule := range cc.Rules {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CORSAllowanceConfig struct {
|
||||
Origin string
|
||||
Methods string
|
||||
ExposedHeaders string
|
||||
AllowCredentials string
|
||||
AllowHeaders string
|
||||
MaxAge *int32
|
||||
}
|
||||
|
||||
// IsAllowed walks through the CORS rules and finds the first one allowing access.
|
||||
// If no rule grants access, returns 'AccessForbidden'
|
||||
func (cc *CORSConfiguration) IsAllowed(origin string, method CORSHTTPMethod, headers []CORSHeader) (*CORSAllowanceConfig, error) {
|
||||
// if method is empty, anyways cors is forbidden
|
||||
// skip, without going through the rules
|
||||
if method.IsEmpty() {
|
||||
debuglogger.Logf("empty Access-Control-Request-Method")
|
||||
return nil, s3err.GetAPIError(s3err.ErrCORSForbidden)
|
||||
}
|
||||
for _, rule := range cc.Rules {
|
||||
// find the first rule granting access
|
||||
if isAllowed, wilcardOrigin := rule.Match(origin, method, headers); isAllowed {
|
||||
o := origin
|
||||
allowCredentials := "true"
|
||||
if wilcardOrigin {
|
||||
o = "*"
|
||||
allowCredentials = "false"
|
||||
}
|
||||
|
||||
return &CORSAllowanceConfig{
|
||||
Origin: o,
|
||||
AllowCredentials: allowCredentials,
|
||||
Methods: rule.GetAllowedMethods(),
|
||||
ExposedHeaders: rule.GetExposeHeaders(),
|
||||
AllowHeaders: buildAllowedHeaders(headers),
|
||||
MaxAge: rule.MaxAgeSeconds,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// if no matching rule is found, return AccessForbidden
|
||||
return nil, s3err.GetAPIError(s3err.ErrCORSForbidden)
|
||||
}
|
||||
|
||||
type CORSRule struct {
|
||||
AllowedMethods []CORSHTTPMethod `xml:"AllowedMethod"`
|
||||
AllowedHeaders []CORSHeader `xml:"AllowedHeader"`
|
||||
ExposeHeaders []CORSHeader `xml:"ExposeHeader"`
|
||||
AllowedOrigins []string `xml:"AllowedOrigin"`
|
||||
ID *string
|
||||
MaxAgeSeconds *int32
|
||||
}
|
||||
|
||||
// Validate validates and returns error if CORS configuration has invalid rule
|
||||
func (cr *CORSRule) Validate() error {
|
||||
// validate CORS allowed headers
|
||||
for _, header := range cr.AllowedHeaders {
|
||||
if !header.IsValid() {
|
||||
debuglogger.Logf("invalid CORS allowed header: %s", header)
|
||||
return s3err.GetInvalidCORSHeaderErr(header.String())
|
||||
}
|
||||
}
|
||||
// validate CORS allowed methods
|
||||
for _, method := range cr.AllowedMethods {
|
||||
if !method.IsValid() {
|
||||
debuglogger.Logf("invalid CORS allowed method: %s", method)
|
||||
return s3err.GetUnsopportedCORSMethodErr(method.String())
|
||||
}
|
||||
}
|
||||
// validate CORS expose headers
|
||||
for _, header := range cr.ExposeHeaders {
|
||||
if !header.IsValid() {
|
||||
debuglogger.Logf("invalid CORS exposed header: %s", header)
|
||||
return s3err.GetInvalidCORSHeaderErr(header.String())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match matches the provided origin, method and headers with the
|
||||
// CORS configuration rule
|
||||
// if the matching origin is "*", it returns true as the first argument
|
||||
func (cr *CORSRule) Match(origin string, method CORSHTTPMethod, headers []CORSHeader) (bool, bool) {
|
||||
wildcardOrigin := false
|
||||
originFound := false
|
||||
|
||||
// check if the provided origin exists in CORS AllowedOrigins
|
||||
for _, or := range cr.AllowedOrigins {
|
||||
if wildcardMatch(or, origin) {
|
||||
originFound = true
|
||||
if or == "*" {
|
||||
// mark wildcardOrigin as true, if "*" is found in AllowedOrigins
|
||||
wildcardOrigin = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !originFound {
|
||||
return false, false
|
||||
}
|
||||
|
||||
// cache the CORS AllowedMethods in a map
|
||||
allowedMethods := cacheCORSMethods(cr.AllowedMethods)
|
||||
// check if the provided method exists in CORS AllowedMethods
|
||||
if _, ok := allowedMethods[method]; !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
// check is CORS rule allowed headers match
|
||||
// with the requested allowed headers
|
||||
for _, reqHeader := range headers {
|
||||
match := false
|
||||
for _, header := range cr.AllowedHeaders {
|
||||
if wildcardMatch(header.ToLower(), reqHeader.ToLower()) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !match {
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
return true, wildcardOrigin
|
||||
}
|
||||
|
||||
// GetExposeHeaders returns comma separated CORS expose headers
|
||||
func (cr *CORSRule) GetExposeHeaders() string {
|
||||
var result strings.Builder
|
||||
|
||||
for i, h := range cr.ExposeHeaders {
|
||||
if i > 0 {
|
||||
result.WriteString(", ")
|
||||
}
|
||||
result.WriteString(h.String())
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// buildAllowedHeaders builds a comma separated string from []CORSHeader
|
||||
func buildAllowedHeaders(headers []CORSHeader) string {
|
||||
var result strings.Builder
|
||||
|
||||
for i, h := range headers {
|
||||
if i > 0 {
|
||||
result.WriteString(", ")
|
||||
}
|
||||
result.WriteString(h.ToLower())
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// GetAllowedMethods returns comma separated CORS allowed methods
|
||||
func (cr *CORSRule) GetAllowedMethods() string {
|
||||
var result strings.Builder
|
||||
|
||||
for i, m := range cr.AllowedMethods {
|
||||
if i > 0 {
|
||||
result.WriteString(", ")
|
||||
}
|
||||
result.WriteString(m.String())
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ParseCORSOutput parses raw bytes to 'CORSConfiguration'
|
||||
func ParseCORSOutput(data []byte) (*CORSConfiguration, error) {
|
||||
var config CORSConfiguration
|
||||
err := xml.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
debuglogger.Logf("unmarshal cors output: %v", err)
|
||||
return nil, fmt.Errorf("failed to parse cors config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func cacheCORSMethods(input []CORSHTTPMethod) map[CORSHTTPMethod]struct{} {
|
||||
result := make(map[CORSHTTPMethod]struct{}, len(input))
|
||||
for _, el := range input {
|
||||
result[el] = struct{}{}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseCORSHeaders parses/validates Access-Control-Request-Headers
|
||||
// and returns []CORSHeaders
|
||||
func ParseCORSHeaders(headers string) ([]CORSHeader, error) {
|
||||
result := []CORSHeader{}
|
||||
if headers == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
headersSplitted := strings.Split(headers, ",")
|
||||
for _, h := range headersSplitted {
|
||||
corsHeader := CORSHeader(strings.TrimSpace(h))
|
||||
if corsHeader == "" || !corsHeader.IsValid() {
|
||||
debuglogger.Logf("invalid access control header: %s", h)
|
||||
return nil, s3err.GetInvalidCORSRequestHeaderErr(h)
|
||||
}
|
||||
result = append(result, corsHeader)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func wildcardMatch(pattern, input string) bool {
|
||||
pIdx, sIdx := 0, 0
|
||||
starIdx, matchIdx := -1, 0
|
||||
|
||||
for sIdx < len(input) {
|
||||
if pIdx < len(pattern) && pattern[pIdx] == input[sIdx] {
|
||||
// exact match of current char
|
||||
sIdx++
|
||||
pIdx++
|
||||
} else if pIdx < len(pattern) && pattern[pIdx] == '*' {
|
||||
// remember star position
|
||||
starIdx = pIdx
|
||||
matchIdx = sIdx
|
||||
pIdx++
|
||||
} else if starIdx != -1 {
|
||||
// backtrack: try to match more characters with '*'
|
||||
pIdx = starIdx + 1
|
||||
matchIdx++
|
||||
sIdx = matchIdx
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// skip trailing stars
|
||||
for pIdx < len(pattern) && pattern[pIdx] == '*' {
|
||||
pIdx++
|
||||
}
|
||||
|
||||
return pIdx == len(pattern)
|
||||
}
|
||||
736
auth/bucket_cors_test.go
Normal file
736
auth/bucket_cors_test.go
Normal file
@@ -0,0 +1,736 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func TestCORSHeader_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header CORSHeader
|
||||
want bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"valid", "X-Custom-Header", true},
|
||||
{"invalid_1", "Invalid Header", false},
|
||||
{"invalid_2", "invalid/header", false},
|
||||
{"invalid_3", "Invalid\tHeader", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.header.IsValid(); got != tt.want {
|
||||
t.Errorf("IsValid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSHTTPMethod_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method CORSHTTPMethod
|
||||
want bool
|
||||
}{
|
||||
{"empty valid", "", true},
|
||||
{"GET valid", http.MethodGet, true},
|
||||
{"HEAD valid", http.MethodHead, true},
|
||||
{"PUT valid", http.MethodPut, true},
|
||||
{"POST valid", http.MethodPost, true},
|
||||
{"DELETE valid", http.MethodDelete, true},
|
||||
{"get valid", "get", false},
|
||||
{"put valid", "put", false},
|
||||
{"post valid", "post", false},
|
||||
{"head valid", "head", false},
|
||||
{"invalid", "FOO", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.method.IsValid(); got != tt.want {
|
||||
t.Errorf("IsValid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSHeader_ToLower(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header CORSHeader
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "already lowercase",
|
||||
header: CORSHeader("content-type"),
|
||||
want: "content-type",
|
||||
},
|
||||
{
|
||||
name: "mixed case",
|
||||
header: CORSHeader("X-CuStOm-HeAdEr"),
|
||||
want: "x-custom-header",
|
||||
},
|
||||
{
|
||||
name: "uppercase",
|
||||
header: CORSHeader("AUTHORIZATION"),
|
||||
want: "authorization",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
header: CORSHeader(""),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "numeric and symbols",
|
||||
header: CORSHeader("X-123-HEADER"),
|
||||
want: "x-123-header",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.header.ToLower()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSHTTPMethod_IsEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method CORSHTTPMethod
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty string is empty",
|
||||
method: CORSHTTPMethod(""),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "GET method is not empty",
|
||||
method: CORSHTTPMethod("GET"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "random string is not empty",
|
||||
method: CORSHTTPMethod("FOO"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lowercase get is not empty (case sensitive)",
|
||||
method: CORSHTTPMethod("get"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.method.IsEmpty()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSConfiguration_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *CORSConfiguration
|
||||
want error
|
||||
}{
|
||||
{"nil config", nil, s3err.GetAPIError(s3err.ErrMalformedXML)},
|
||||
{"nil rules", &CORSConfiguration{}, s3err.GetAPIError(s3err.ErrMalformedXML)},
|
||||
{"empty rules", &CORSConfiguration{Rules: []CORSRule{}}, s3err.GetAPIError(s3err.ErrMalformedXML)},
|
||||
{"invalid rule", &CORSConfiguration{Rules: []CORSRule{{AllowedHeaders: []CORSHeader{"Invalid Header"}}}}, s3err.GetInvalidCORSHeaderErr("Invalid Header")},
|
||||
{"valid rule", &CORSConfiguration{Rules: []CORSRule{{
|
||||
AllowedOrigins: []string{"origin"},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
ExposeHeaders: []CORSHeader{"X-Expose"},
|
||||
}}}, nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
assert.EqualValues(t, tt.want, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSConfiguration_IsAllowed(t *testing.T) {
|
||||
type input struct {
|
||||
cfg *CORSConfiguration
|
||||
origin string
|
||||
method CORSHTTPMethod
|
||||
headers []CORSHeader
|
||||
}
|
||||
type output struct {
|
||||
result *CORSAllowanceConfig
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
input input
|
||||
output output
|
||||
}{
|
||||
{
|
||||
name: "allowed exact origin",
|
||||
input: input{
|
||||
cfg: &CORSConfiguration{Rules: []CORSRule{{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
}}},
|
||||
origin: "http://allowed.com",
|
||||
method: http.MethodGet,
|
||||
headers: []CORSHeader{"X-Test"},
|
||||
},
|
||||
output: output{
|
||||
result: &CORSAllowanceConfig{
|
||||
Origin: "http://allowed.com",
|
||||
AllowCredentials: "true",
|
||||
Methods: http.MethodGet,
|
||||
AllowHeaders: "x-test",
|
||||
ExposedHeaders: "",
|
||||
MaxAge: nil,
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allowed wildcard origin",
|
||||
input: input{
|
||||
cfg: &CORSConfiguration{Rules: []CORSRule{{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
}}},
|
||||
origin: "anything",
|
||||
method: http.MethodGet,
|
||||
headers: []CORSHeader{"X-Test"},
|
||||
},
|
||||
output: output{
|
||||
result: &CORSAllowanceConfig{
|
||||
Origin: "*",
|
||||
AllowCredentials: "false",
|
||||
AllowHeaders: "x-test",
|
||||
Methods: http.MethodGet,
|
||||
ExposedHeaders: "",
|
||||
MaxAge: nil,
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forbidden no matching origin",
|
||||
input: input{
|
||||
cfg: &CORSConfiguration{Rules: []CORSRule{{
|
||||
AllowedOrigins: []string{"http://nope.com"},
|
||||
}}},
|
||||
origin: "http://not-allowed.com",
|
||||
method: http.MethodGet,
|
||||
},
|
||||
output: output{
|
||||
result: nil,
|
||||
err: s3err.GetAPIError(s3err.ErrCORSForbidden),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forbidden method not allowed",
|
||||
input: input{
|
||||
cfg: &CORSConfiguration{Rules: []CORSRule{{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodPost},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
}}},
|
||||
origin: "http://allowed.com",
|
||||
method: http.MethodGet,
|
||||
headers: []CORSHeader{"X-Test"},
|
||||
},
|
||||
output: output{
|
||||
result: nil,
|
||||
err: s3err.GetAPIError(s3err.ErrCORSForbidden),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forbidden header not allowed",
|
||||
input: input{
|
||||
cfg: &CORSConfiguration{Rules: []CORSRule{{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
}}},
|
||||
origin: "http://allowed.com",
|
||||
method: http.MethodGet,
|
||||
headers: []CORSHeader{"X-Nope"},
|
||||
},
|
||||
output: output{
|
||||
result: nil,
|
||||
err: s3err.GetAPIError(s3err.ErrCORSForbidden),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.input.cfg.IsAllowed(tt.input.origin, tt.input.method, tt.input.headers)
|
||||
assert.EqualValues(t, tt.output.err, err)
|
||||
assert.EqualValues(t, tt.output.result, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSRule_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rule CORSRule
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "valid rule",
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid allowed methods",
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{"invalid_method"},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
},
|
||||
want: s3err.GetUnsopportedCORSMethodErr("invalid_method"),
|
||||
},
|
||||
{
|
||||
name: "invalid allowed header",
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"Invalid Header"},
|
||||
},
|
||||
want: s3err.GetInvalidCORSHeaderErr("Invalid Header"),
|
||||
},
|
||||
{
|
||||
name: "invalid allowed header",
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"Content-Length"},
|
||||
ExposeHeaders: []CORSHeader{"Content-Encoding", "invalid header"},
|
||||
},
|
||||
want: s3err.GetInvalidCORSHeaderErr("invalid header"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.rule.Validate()
|
||||
assert.EqualValues(t, tt.want, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSRule_Match(t *testing.T) {
|
||||
type input struct {
|
||||
rule CORSRule
|
||||
origin string
|
||||
method CORSHTTPMethod
|
||||
headers []CORSHeader
|
||||
}
|
||||
type output struct {
|
||||
isAllowed bool
|
||||
isWildcard bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
input input
|
||||
output output
|
||||
}{
|
||||
{
|
||||
name: "exact origin and method match",
|
||||
input: input{
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
},
|
||||
origin: "http://allowed.com",
|
||||
method: http.MethodGet,
|
||||
headers: []CORSHeader{"X-Test"},
|
||||
},
|
||||
output: output{isAllowed: true, isWildcard: false},
|
||||
},
|
||||
{
|
||||
name: "wildcard origin match",
|
||||
input: input{
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodPost},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
},
|
||||
origin: "http://random.com",
|
||||
method: http.MethodPost,
|
||||
headers: []CORSHeader{"X-Test"},
|
||||
},
|
||||
output: output{isAllowed: true, isWildcard: true},
|
||||
},
|
||||
{
|
||||
name: "wildcard containing origin match",
|
||||
input: input{
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://random*"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodPost},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
},
|
||||
origin: "http://random.com",
|
||||
method: http.MethodPost,
|
||||
headers: []CORSHeader{"X-Test"},
|
||||
},
|
||||
output: output{isAllowed: true, isWildcard: false},
|
||||
},
|
||||
{
|
||||
name: "wildcard allowed headers match",
|
||||
input: input{
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://something.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodPost},
|
||||
AllowedHeaders: []CORSHeader{"X-*"},
|
||||
},
|
||||
origin: "http://something.com",
|
||||
method: http.MethodPost,
|
||||
headers: []CORSHeader{"X-Test", "X-Something", "X-Anyting"},
|
||||
},
|
||||
output: output{isAllowed: true, isWildcard: false},
|
||||
},
|
||||
{
|
||||
name: "origin mismatch",
|
||||
input: input{
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
},
|
||||
origin: "http://notallowed.com",
|
||||
method: http.MethodGet,
|
||||
headers: []CORSHeader{"X-Test"},
|
||||
},
|
||||
output: output{isAllowed: false, isWildcard: false},
|
||||
},
|
||||
{
|
||||
name: "method mismatch",
|
||||
input: input{
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodPost},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
},
|
||||
origin: "http://allowed.com",
|
||||
method: http.MethodGet,
|
||||
headers: []CORSHeader{"X-Test"},
|
||||
},
|
||||
output: output{isAllowed: false, isWildcard: false},
|
||||
},
|
||||
{
|
||||
name: "header mismatch",
|
||||
input: input{
|
||||
rule: CORSRule{
|
||||
AllowedOrigins: []string{"http://allowed.com"},
|
||||
AllowedMethods: []CORSHTTPMethod{http.MethodGet},
|
||||
AllowedHeaders: []CORSHeader{"X-Test"},
|
||||
},
|
||||
origin: "http://allowed.com",
|
||||
method: http.MethodGet,
|
||||
headers: []CORSHeader{"X-Other"},
|
||||
},
|
||||
output: output{isAllowed: false, isWildcard: false},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isAllowed, wild := tt.input.rule.Match(tt.input.origin, tt.input.method, tt.input.headers)
|
||||
assert.Equal(t, tt.output.isAllowed, isAllowed)
|
||||
assert.Equal(t, tt.output.isWildcard, wild)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExposeHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rule CORSRule
|
||||
want string
|
||||
}{
|
||||
{"multiple headers", CORSRule{ExposeHeaders: []CORSHeader{"Content-Length", "Content-Type", "Content-Encoding"}}, "Content-Length, Content-Type, Content-Encoding"},
|
||||
{"single header", CORSRule{ExposeHeaders: []CORSHeader{"Authorization"}}, "Authorization"},
|
||||
{"no headers", CORSRule{}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.rule.GetExposeHeaders()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAllowedHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers []CORSHeader
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty slice returns empty string",
|
||||
headers: []CORSHeader{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single header lowercase",
|
||||
headers: []CORSHeader{"Content-Type"},
|
||||
want: "content-type",
|
||||
},
|
||||
{
|
||||
name: "multiple headers lowercased with commas",
|
||||
headers: []CORSHeader{"Content-Type", "X-Custom-Header", "Authorization"},
|
||||
want: "content-type, x-custom-header, authorization",
|
||||
},
|
||||
{
|
||||
name: "already lowercase header",
|
||||
headers: []CORSHeader{"accept"},
|
||||
want: "accept",
|
||||
},
|
||||
{
|
||||
name: "mixed case headers",
|
||||
headers: []CORSHeader{"ACCEPT", "x-Powered-By"},
|
||||
want: "accept, x-powered-by",
|
||||
},
|
||||
{
|
||||
name: "empty header value",
|
||||
headers: []CORSHeader{""},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildAllowedHeaders(tt.headers)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllowedMethods(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rule CORSRule
|
||||
want string
|
||||
}{
|
||||
{"multiple methods", CORSRule{AllowedMethods: []CORSHTTPMethod{http.MethodGet, http.MethodPost, http.MethodPut}}, "GET, POST, PUT"},
|
||||
{"single method", CORSRule{AllowedMethods: []CORSHTTPMethod{http.MethodGet}}, "GET"},
|
||||
{"no methods", CORSRule{}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.rule.GetAllowedMethods()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCORSOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want bool
|
||||
}{
|
||||
{"valid", `<CORSConfiguration><CORSRule></CORSRule></CORSConfiguration>`, true},
|
||||
{"invalid xml", `<CORSConfiguration><CORSRule>`, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg, err := ParseCORSOutput([]byte(tt.data))
|
||||
if (err == nil) != tt.want {
|
||||
t.Errorf("ParseCORSOutput() err = %v, want success=%v", err, tt.want)
|
||||
}
|
||||
if tt.want && cfg == nil {
|
||||
t.Errorf("Expected non-nil config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheCORSProps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []CORSHTTPMethod
|
||||
want map[string]struct{}
|
||||
}{
|
||||
{
|
||||
name: "empty CORSHTTPMethod slice",
|
||||
in: []CORSHTTPMethod{},
|
||||
want: map[string]struct{}{},
|
||||
},
|
||||
{
|
||||
name: "single CORSHTTPMethod",
|
||||
in: []CORSHTTPMethod{http.MethodGet},
|
||||
want: map[string]struct{}{http.MethodGet: {}},
|
||||
},
|
||||
{
|
||||
name: "multiple CORSHTTPMethods",
|
||||
in: []CORSHTTPMethod{http.MethodGet, http.MethodPost, http.MethodPut},
|
||||
want: map[string]struct{}{
|
||||
http.MethodGet: {},
|
||||
http.MethodPost: {},
|
||||
http.MethodPut: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := cacheCORSMethods(tt.in)
|
||||
assert.Equal(t, len(tt.want), len(got))
|
||||
for key := range tt.want {
|
||||
_, ok := got[CORSHTTPMethod(key)]
|
||||
assert.True(t, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCORSHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []CORSHeader
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
in: "",
|
||||
want: []CORSHeader{},
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "single valid header",
|
||||
in: "X-Test",
|
||||
want: []CORSHeader{"X-Test"},
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "multiple valid headers with spaces",
|
||||
in: "X-Test, Content-Type, Authorization",
|
||||
want: []CORSHeader{"X-Test", "Content-Type", "Authorization"},
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "header with leading/trailing spaces",
|
||||
in: " X-Test ",
|
||||
want: []CORSHeader{"X-Test"},
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "contains invalid header",
|
||||
in: "X-Test, Invalid Header, Content-Type",
|
||||
want: nil,
|
||||
err: s3err.GetInvalidCORSRequestHeaderErr(" Invalid Header"),
|
||||
},
|
||||
{
|
||||
name: "only invalid header",
|
||||
in: "Invalid Header",
|
||||
want: nil,
|
||||
err: s3err.GetInvalidCORSRequestHeaderErr("Invalid Header"),
|
||||
},
|
||||
{
|
||||
name: "multiple commas in a row",
|
||||
in: "X-Test,,Content-Type",
|
||||
want: nil,
|
||||
err: s3err.GetInvalidCORSRequestHeaderErr(""),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseCORSHeaders(tt.in)
|
||||
assert.EqualValues(t, tt.err, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildcardMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Exact match, no wildcards
|
||||
{"exact match", "hello", "hello", true},
|
||||
{"exact mismatch", "hello", "hell", false},
|
||||
// Single '*' matching zero chars
|
||||
{"star matches zero chars", "he*lo", "helo", true},
|
||||
// Single '*' matching multiple chars
|
||||
{"star matches multiple chars", "he*o", "heyyyyyo", true},
|
||||
// '*' at start
|
||||
{"star at start", "*world", "hello world", true},
|
||||
// '*' at end
|
||||
{"star at end", "hello*", "hello there", true},
|
||||
// '*' matches whole string
|
||||
{"only star", "*", "anything", true},
|
||||
{"only star empty", "*", "", true},
|
||||
// Multiple '*'s
|
||||
{"multiple stars", "a*b*c", "axxxbzzzzyc", true},
|
||||
{"multiple stars no match", "a*b*c", "axxxbzzzzy", false},
|
||||
// Backtracking needed
|
||||
{"backtracking required", "a*b*c", "ab123c", true},
|
||||
// No match with star present
|
||||
{"star but mismatch", "he*world", "hey there", false},
|
||||
// Trailing stars in pattern
|
||||
{"trailing stars match", "abc**", "abc", true},
|
||||
{"trailing stars match longer", "abc**", "abccc", true},
|
||||
// Empty pattern cases
|
||||
{"empty pattern and empty input", "", "", true},
|
||||
{"empty pattern non-empty input", "", "a", false},
|
||||
{"only stars pattern with empty input", "***", "", true},
|
||||
// Pattern longer than input
|
||||
{"pattern longer no star", "abcd", "abc", false},
|
||||
// Input longer but no star
|
||||
{"input longer no star", "abc", "abcd", false},
|
||||
// Complex interleaved match
|
||||
{"complex interleaved", "*a*b*cd*", "xxaYYbZZcd123", true},
|
||||
// Star match at the end after mismatch
|
||||
{"mismatch then star match", "ab*xyz", "abzzzxyz", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := wildcardMatch(tt.pattern, tt.input)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,14 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
var ErrAccessDenied = errors.New("access denied")
|
||||
|
||||
type policyErr string
|
||||
|
||||
func (p policyErr) Error() string {
|
||||
@@ -89,6 +92,36 @@ func (bp *BucketPolicy) isAllowed(principal string, action Action, resource stri
|
||||
return isAllowed
|
||||
}
|
||||
|
||||
// IsPublicFor checks if the bucket policy statements contain
|
||||
// an entity granting public access to the given resource and action
|
||||
func (bp *BucketPolicy) isPublicFor(resource string, action Action) bool {
|
||||
var isAllowed bool
|
||||
for _, statement := range bp.Statement {
|
||||
if statement.isPublicFor(resource, action) {
|
||||
switch statement.Effect {
|
||||
case BucketPolicyAccessTypeAllow:
|
||||
isAllowed = true
|
||||
case BucketPolicyAccessTypeDeny:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isAllowed
|
||||
}
|
||||
|
||||
// IsPublic checks if one of bucket policy statments grant
|
||||
// public access to ALL users
|
||||
func (bp *BucketPolicy) IsPublic() bool {
|
||||
for _, statement := range bp.Statement {
|
||||
if statement.isPublic() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type BucketPolicyItem struct {
|
||||
Effect BucketPolicyAccessType `json:"Effect"`
|
||||
Principals Principals `json:"Principal"`
|
||||
@@ -134,6 +167,18 @@ func (bpi *BucketPolicyItem) findMatch(principal string, action Action, resource
|
||||
return false
|
||||
}
|
||||
|
||||
// isPublicFor checks if the bucket policy statemant grants public access
|
||||
// for given resource and action
|
||||
func (bpi *BucketPolicyItem) isPublicFor(resource string, action Action) bool {
|
||||
return bpi.Principals.isPublic() && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource)
|
||||
}
|
||||
|
||||
// isPublic checks if the statement grants public access
|
||||
// to ALL users
|
||||
func (bpi *BucketPolicyItem) isPublic() bool {
|
||||
return bpi.Principals.isPublic()
|
||||
}
|
||||
|
||||
func getMalformedPolicyError(err error) error {
|
||||
return s3err.APIError{
|
||||
Code: "MalformedPolicy",
|
||||
@@ -142,17 +187,27 @@ func getMalformedPolicyError(err error) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePolicyDocument parses raw bytes to 'BucketPolicy'
|
||||
func ParsePolicyDocument(data []byte) (*BucketPolicy, error) {
|
||||
var policy BucketPolicy
|
||||
if err := json.Unmarshal(data, &policy); err != nil {
|
||||
var pe policyErr
|
||||
if errors.As(err, &pe) {
|
||||
return nil, getMalformedPolicyError(err)
|
||||
}
|
||||
return nil, getMalformedPolicyError(policyErrInvalidPolicy)
|
||||
}
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
func ValidatePolicyDocument(policyBin []byte, bucket string, iam IAMService) error {
|
||||
if len(policyBin) == 0 || policyBin[0] != '{' {
|
||||
return getMalformedPolicyError(policyErrInvalidFirstChar)
|
||||
}
|
||||
var policy BucketPolicy
|
||||
if err := json.Unmarshal(policyBin, &policy); err != nil {
|
||||
var pe policyErr
|
||||
if errors.As(err, &pe) {
|
||||
return getMalformedPolicyError(err)
|
||||
}
|
||||
return getMalformedPolicyError(policyErrInvalidPolicy)
|
||||
policy, err := ParsePolicyDocument(policyBin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(policy.Statement) == 0 {
|
||||
@@ -169,7 +224,7 @@ func ValidatePolicyDocument(policyBin []byte, bucket string, iam IAMService) err
|
||||
func VerifyBucketPolicy(policy []byte, access, bucket, object string, action Action) error {
|
||||
var bucketPolicy BucketPolicy
|
||||
if err := json.Unmarshal(policy, &bucketPolicy); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to parse the bucket policy: %w", err)
|
||||
}
|
||||
|
||||
resource := bucket
|
||||
@@ -183,3 +238,53 @@ func VerifyBucketPolicy(policy []byte, access, bucket, object string, action Act
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Checks if the bucket policy grants public access
|
||||
func VerifyPublicBucketPolicy(policy []byte, bucket, object string, action Action) error {
|
||||
var bucketPolicy BucketPolicy
|
||||
if err := json.Unmarshal(policy, &bucketPolicy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resource := bucket
|
||||
if object != "" {
|
||||
resource += "/" + object
|
||||
}
|
||||
|
||||
if !bucketPolicy.isPublicFor(resource, action) {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchPattern checks if the input string matches the given pattern with wildcard(`*`) and any character(`?`).
|
||||
// - `?` matches exactly one occurrence of any character.
|
||||
// - `*` matches arbitrary many (including zero) occurrences of any character.
|
||||
func matchPattern(pattern, input string) bool {
|
||||
pIdx, sIdx := 0, 0
|
||||
starIdx, matchIdx := -1, 0
|
||||
|
||||
for sIdx < len(input) {
|
||||
if pIdx < len(pattern) && (pattern[pIdx] == '?' || pattern[pIdx] == input[sIdx]) {
|
||||
sIdx++
|
||||
pIdx++
|
||||
} else if pIdx < len(pattern) && pattern[pIdx] == '*' {
|
||||
starIdx = pIdx
|
||||
matchIdx = sIdx
|
||||
pIdx++
|
||||
} else if starIdx != -1 {
|
||||
pIdx = starIdx + 1
|
||||
matchIdx++
|
||||
sIdx = matchIdx
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for pIdx < len(pattern) && pattern[pIdx] == '*' {
|
||||
pIdx++
|
||||
}
|
||||
|
||||
return pIdx == len(pattern)
|
||||
}
|
||||
|
||||
@@ -22,86 +22,144 @@ import (
|
||||
type Action string
|
||||
|
||||
const (
|
||||
GetBucketAclAction Action = "s3:GetBucketAcl"
|
||||
CreateBucketAction Action = "s3:CreateBucket"
|
||||
PutBucketAclAction Action = "s3:PutBucketAcl"
|
||||
DeleteBucketAction Action = "s3:DeleteBucket"
|
||||
PutBucketVersioningAction Action = "s3:PutBucketVersioning"
|
||||
GetBucketVersioningAction Action = "s3:GetBucketVersioning"
|
||||
PutBucketPolicyAction Action = "s3:PutBucketPolicy"
|
||||
GetBucketPolicyAction Action = "s3:GetBucketPolicy"
|
||||
DeleteBucketPolicyAction Action = "s3:DeleteBucketPolicy"
|
||||
AbortMultipartUploadAction Action = "s3:AbortMultipartUpload"
|
||||
ListMultipartUploadPartsAction Action = "s3:ListMultipartUploadParts"
|
||||
ListBucketMultipartUploadsAction Action = "s3:ListBucketMultipartUploads"
|
||||
PutObjectAction Action = "s3:PutObject"
|
||||
GetObjectAction Action = "s3:GetObject"
|
||||
GetObjectVersionAction Action = "s3:GetObjectVersion"
|
||||
DeleteObjectAction Action = "s3:DeleteObject"
|
||||
GetObjectAclAction Action = "s3:GetObjectAcl"
|
||||
GetObjectAttributesAction Action = "s3:GetObjectAttributes"
|
||||
PutObjectAclAction Action = "s3:PutObjectAcl"
|
||||
RestoreObjectAction Action = "s3:RestoreObject"
|
||||
GetBucketTaggingAction Action = "s3:GetBucketTagging"
|
||||
PutBucketTaggingAction Action = "s3:PutBucketTagging"
|
||||
GetObjectTaggingAction Action = "s3:GetObjectTagging"
|
||||
PutObjectTaggingAction Action = "s3:PutObjectTagging"
|
||||
DeleteObjectTaggingAction Action = "s3:DeleteObjectTagging"
|
||||
ListBucketVersionsAction Action = "s3:ListBucketVersions"
|
||||
ListBucketAction Action = "s3:ListBucket"
|
||||
GetBucketObjectLockConfigurationAction Action = "s3:GetBucketObjectLockConfiguration"
|
||||
PutBucketObjectLockConfigurationAction Action = "s3:PutBucketObjectLockConfiguration"
|
||||
GetObjectLegalHoldAction Action = "s3:GetObjectLegalHold"
|
||||
PutObjectLegalHoldAction Action = "s3:PutObjectLegalHold"
|
||||
GetObjectRetentionAction Action = "s3:GetObjectRetention"
|
||||
PutObjectRetentionAction Action = "s3:PutObjectRetention"
|
||||
BypassGovernanceRetentionAction Action = "s3:BypassGovernanceRetention"
|
||||
PutBucketOwnershipControlsAction Action = "s3:PutBucketOwnershipControls"
|
||||
GetBucketOwnershipControlsAction Action = "s3:GetBucketOwnershipControls"
|
||||
PutBucketCorsAction Action = "s3:PutBucketCORS"
|
||||
GetBucketCorsAction Action = "s3:GetBucketCORS"
|
||||
AllActions Action = "s3:*"
|
||||
GetBucketAclAction Action = "s3:GetBucketAcl"
|
||||
CreateBucketAction Action = "s3:CreateBucket"
|
||||
PutBucketAclAction Action = "s3:PutBucketAcl"
|
||||
DeleteBucketAction Action = "s3:DeleteBucket"
|
||||
PutBucketVersioningAction Action = "s3:PutBucketVersioning"
|
||||
GetBucketVersioningAction Action = "s3:GetBucketVersioning"
|
||||
PutBucketPolicyAction Action = "s3:PutBucketPolicy"
|
||||
GetBucketPolicyAction Action = "s3:GetBucketPolicy"
|
||||
DeleteBucketPolicyAction Action = "s3:DeleteBucketPolicy"
|
||||
AbortMultipartUploadAction Action = "s3:AbortMultipartUpload"
|
||||
ListMultipartUploadPartsAction Action = "s3:ListMultipartUploadParts"
|
||||
ListBucketMultipartUploadsAction Action = "s3:ListBucketMultipartUploads"
|
||||
PutObjectAction Action = "s3:PutObject"
|
||||
GetObjectAction Action = "s3:GetObject"
|
||||
GetObjectVersionAction Action = "s3:GetObjectVersion"
|
||||
DeleteObjectAction Action = "s3:DeleteObject"
|
||||
GetObjectAclAction Action = "s3:GetObjectAcl"
|
||||
GetObjectAttributesAction Action = "s3:GetObjectAttributes"
|
||||
PutObjectAclAction Action = "s3:PutObjectAcl"
|
||||
RestoreObjectAction Action = "s3:RestoreObject"
|
||||
GetBucketTaggingAction Action = "s3:GetBucketTagging"
|
||||
PutBucketTaggingAction Action = "s3:PutBucketTagging"
|
||||
GetObjectTaggingAction Action = "s3:GetObjectTagging"
|
||||
PutObjectTaggingAction Action = "s3:PutObjectTagging"
|
||||
DeleteObjectTaggingAction Action = "s3:DeleteObjectTagging"
|
||||
ListBucketVersionsAction Action = "s3:ListBucketVersions"
|
||||
ListBucketAction Action = "s3:ListBucket"
|
||||
GetBucketObjectLockConfigurationAction Action = "s3:GetBucketObjectLockConfiguration"
|
||||
PutBucketObjectLockConfigurationAction Action = "s3:PutBucketObjectLockConfiguration"
|
||||
GetObjectLegalHoldAction Action = "s3:GetObjectLegalHold"
|
||||
PutObjectLegalHoldAction Action = "s3:PutObjectLegalHold"
|
||||
GetObjectRetentionAction Action = "s3:GetObjectRetention"
|
||||
PutObjectRetentionAction Action = "s3:PutObjectRetention"
|
||||
BypassGovernanceRetentionAction Action = "s3:BypassGovernanceRetention"
|
||||
PutBucketOwnershipControlsAction Action = "s3:PutBucketOwnershipControls"
|
||||
GetBucketOwnershipControlsAction Action = "s3:GetBucketOwnershipControls"
|
||||
PutBucketCorsAction Action = "s3:PutBucketCORS"
|
||||
GetBucketCorsAction Action = "s3:GetBucketCORS"
|
||||
PutAnalyticsConfigurationAction Action = "s3:PutAnalyticsConfiguration"
|
||||
GetAnalyticsConfigurationAction Action = "s3:GetAnalyticsConfiguration"
|
||||
PutEncryptionConfigurationAction Action = "s3:PutEncryptionConfiguration"
|
||||
GetEncryptionConfigurationAction Action = "s3:GetEncryptionConfiguration"
|
||||
PutIntelligentTieringConfigurationAction Action = "s3:PutIntelligentTieringConfiguration"
|
||||
GetIntelligentTieringConfigurationAction Action = "s3:GetIntelligentTieringConfiguration"
|
||||
PutInventoryConfigurationAction Action = "s3:PutInventoryConfiguration"
|
||||
GetInventoryConfigurationAction Action = "s3:GetInventoryConfiguration"
|
||||
PutLifecycleConfigurationAction Action = "s3:PutLifecycleConfiguration"
|
||||
GetLifecycleConfigurationAction Action = "s3:GetLifecycleConfiguration"
|
||||
PutBucketLoggingAction Action = "s3:PutBucketLogging"
|
||||
GetBucketLoggingAction Action = "s3:GetBucketLogging"
|
||||
PutBucketRequestPaymentAction Action = "s3:PutBucketRequestPayment"
|
||||
GetBucketRequestPaymentAction Action = "s3:GetBucketRequestPayment"
|
||||
PutMetricsConfigurationAction Action = "s3:PutMetricsConfiguration"
|
||||
GetMetricsConfigurationAction Action = "s3:GetMetricsConfiguration"
|
||||
PutReplicationConfigurationAction Action = "s3:PutReplicationConfiguration"
|
||||
GetReplicationConfigurationAction Action = "s3:GetReplicationConfiguration"
|
||||
PutBucketPublicAccessBlockAction Action = "s3:PutBucketPublicAccessBlock"
|
||||
GetBucketPublicAccessBlockAction Action = "s3:GetBucketPublicAccessBlock"
|
||||
PutBucketNotificationAction Action = "s3:PutBucketNotification"
|
||||
GetBucketNotificationAction Action = "s3:GetBucketNotification"
|
||||
PutAccelerateConfigurationAction Action = "s3:PutAccelerateConfiguration"
|
||||
GetAccelerateConfigurationAction Action = "s3:GetAccelerateConfiguration"
|
||||
PutBucketWebsiteAction Action = "s3:PutBucketWebsite"
|
||||
GetBucketWebsiteAction Action = "s3:GetBucketWebsite"
|
||||
GetBucketPolicyStatusAction Action = "s3:GetBucketPolicyStatus"
|
||||
GetBucketLocationAction Action = "s3:GetBucketLocation"
|
||||
|
||||
AllActions Action = "s3:*"
|
||||
)
|
||||
|
||||
var supportedActionList = map[Action]struct{}{
|
||||
GetBucketAclAction: {},
|
||||
CreateBucketAction: {},
|
||||
PutBucketAclAction: {},
|
||||
DeleteBucketAction: {},
|
||||
PutBucketVersioningAction: {},
|
||||
GetBucketVersioningAction: {},
|
||||
PutBucketPolicyAction: {},
|
||||
GetBucketPolicyAction: {},
|
||||
DeleteBucketPolicyAction: {},
|
||||
AbortMultipartUploadAction: {},
|
||||
ListMultipartUploadPartsAction: {},
|
||||
ListBucketMultipartUploadsAction: {},
|
||||
PutObjectAction: {},
|
||||
GetObjectAction: {},
|
||||
GetObjectVersionAction: {},
|
||||
DeleteObjectAction: {},
|
||||
GetObjectAclAction: {},
|
||||
GetObjectAttributesAction: {},
|
||||
PutObjectAclAction: {},
|
||||
RestoreObjectAction: {},
|
||||
GetBucketTaggingAction: {},
|
||||
PutBucketTaggingAction: {},
|
||||
GetObjectTaggingAction: {},
|
||||
PutObjectTaggingAction: {},
|
||||
DeleteObjectTaggingAction: {},
|
||||
ListBucketVersionsAction: {},
|
||||
ListBucketAction: {},
|
||||
PutBucketObjectLockConfigurationAction: {},
|
||||
GetObjectLegalHoldAction: {},
|
||||
PutObjectLegalHoldAction: {},
|
||||
GetObjectRetentionAction: {},
|
||||
PutObjectRetentionAction: {},
|
||||
BypassGovernanceRetentionAction: {},
|
||||
PutBucketOwnershipControlsAction: {},
|
||||
GetBucketOwnershipControlsAction: {},
|
||||
PutBucketCorsAction: {},
|
||||
GetBucketCorsAction: {},
|
||||
AllActions: {},
|
||||
GetBucketAclAction: {},
|
||||
CreateBucketAction: {},
|
||||
PutBucketAclAction: {},
|
||||
DeleteBucketAction: {},
|
||||
PutBucketVersioningAction: {},
|
||||
GetBucketVersioningAction: {},
|
||||
PutBucketPolicyAction: {},
|
||||
GetBucketPolicyAction: {},
|
||||
DeleteBucketPolicyAction: {},
|
||||
AbortMultipartUploadAction: {},
|
||||
ListMultipartUploadPartsAction: {},
|
||||
ListBucketMultipartUploadsAction: {},
|
||||
PutObjectAction: {},
|
||||
GetObjectAction: {},
|
||||
GetObjectVersionAction: {},
|
||||
DeleteObjectAction: {},
|
||||
GetObjectAclAction: {},
|
||||
GetObjectAttributesAction: {},
|
||||
PutObjectAclAction: {},
|
||||
RestoreObjectAction: {},
|
||||
GetBucketTaggingAction: {},
|
||||
PutBucketTaggingAction: {},
|
||||
GetObjectTaggingAction: {},
|
||||
PutObjectTaggingAction: {},
|
||||
DeleteObjectTaggingAction: {},
|
||||
ListBucketVersionsAction: {},
|
||||
ListBucketAction: {},
|
||||
GetBucketObjectLockConfigurationAction: {},
|
||||
PutBucketObjectLockConfigurationAction: {},
|
||||
GetObjectLegalHoldAction: {},
|
||||
PutObjectLegalHoldAction: {},
|
||||
GetObjectRetentionAction: {},
|
||||
PutObjectRetentionAction: {},
|
||||
BypassGovernanceRetentionAction: {},
|
||||
PutBucketOwnershipControlsAction: {},
|
||||
GetBucketOwnershipControlsAction: {},
|
||||
PutBucketCorsAction: {},
|
||||
GetBucketCorsAction: {},
|
||||
PutAnalyticsConfigurationAction: {},
|
||||
GetAnalyticsConfigurationAction: {},
|
||||
PutEncryptionConfigurationAction: {},
|
||||
GetEncryptionConfigurationAction: {},
|
||||
PutIntelligentTieringConfigurationAction: {},
|
||||
GetIntelligentTieringConfigurationAction: {},
|
||||
PutInventoryConfigurationAction: {},
|
||||
GetInventoryConfigurationAction: {},
|
||||
PutLifecycleConfigurationAction: {},
|
||||
GetLifecycleConfigurationAction: {},
|
||||
PutBucketLoggingAction: {},
|
||||
GetBucketLoggingAction: {},
|
||||
PutBucketRequestPaymentAction: {},
|
||||
GetBucketRequestPaymentAction: {},
|
||||
PutMetricsConfigurationAction: {},
|
||||
GetMetricsConfigurationAction: {},
|
||||
PutReplicationConfigurationAction: {},
|
||||
GetReplicationConfigurationAction: {},
|
||||
PutBucketPublicAccessBlockAction: {},
|
||||
GetBucketPublicAccessBlockAction: {},
|
||||
PutBucketNotificationAction: {},
|
||||
GetBucketNotificationAction: {},
|
||||
PutAccelerateConfigurationAction: {},
|
||||
GetAccelerateConfigurationAction: {},
|
||||
PutBucketWebsiteAction: {},
|
||||
GetBucketWebsiteAction: {},
|
||||
GetBucketPolicyStatusAction: {},
|
||||
GetBucketLocationAction: {},
|
||||
AllActions: {},
|
||||
}
|
||||
|
||||
var supportedObjectActionList = map[Action]struct{}{
|
||||
@@ -136,55 +194,54 @@ func (a Action) IsValid() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if a[len(a)-1] == '*' {
|
||||
pattern := strings.TrimSuffix(string(a), "*")
|
||||
for act := range supportedActionList {
|
||||
if strings.HasPrefix(string(act), pattern) {
|
||||
return nil
|
||||
}
|
||||
// first check for an exact match
|
||||
if _, ok := supportedActionList[a]; ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// walk through the supported actions and try wildcard match
|
||||
for action := range supportedActionList {
|
||||
if action.Match(a) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return policyErrInvalidAction
|
||||
}
|
||||
|
||||
_, found := supportedActionList[a]
|
||||
if !found {
|
||||
return policyErrInvalidAction
|
||||
}
|
||||
return nil
|
||||
return policyErrInvalidAction
|
||||
}
|
||||
|
||||
func getBoolPtr(bl bool) *bool {
|
||||
return &bl
|
||||
}
|
||||
|
||||
// String converts the action to string
|
||||
func (a Action) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
// Match wildcard matches the given pattern to the action
|
||||
func (a Action) Match(pattern Action) bool {
|
||||
return matchPattern(pattern.String(), a.String())
|
||||
}
|
||||
|
||||
// Checks if the action is object action
|
||||
// nil points to 's3:*'
|
||||
func (a Action) IsObjectAction() *bool {
|
||||
if a == AllActions {
|
||||
return nil
|
||||
}
|
||||
if a[len(a)-1] == '*' {
|
||||
pattern := strings.TrimSuffix(string(a), "*")
|
||||
for act := range supportedObjectActionList {
|
||||
if strings.HasPrefix(string(act), pattern) {
|
||||
return getBoolPtr(true)
|
||||
}
|
||||
|
||||
// first find an exact match
|
||||
if _, ok := supportedObjectActionList[a]; ok {
|
||||
return &ok
|
||||
}
|
||||
|
||||
for action := range supportedObjectActionList {
|
||||
if action.Match(a) {
|
||||
return getBoolPtr(true)
|
||||
}
|
||||
|
||||
return getBoolPtr(false)
|
||||
}
|
||||
|
||||
_, found := supportedObjectActionList[a]
|
||||
return &found
|
||||
}
|
||||
|
||||
func (a Action) WildCardMatch(act Action) bool {
|
||||
if strings.HasSuffix(string(a), "*") {
|
||||
pattern := strings.TrimSuffix(string(a), "*")
|
||||
return strings.HasPrefix(string(act), pattern)
|
||||
}
|
||||
return false
|
||||
return getBoolPtr(false)
|
||||
}
|
||||
|
||||
type Actions map[Action]struct{}
|
||||
@@ -233,6 +290,7 @@ func (a Actions) Add(str string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindMatch tries to match the given action to the actions list
|
||||
func (a Actions) FindMatch(action Action) bool {
|
||||
_, ok := a[AllActions]
|
||||
if ok {
|
||||
@@ -244,8 +302,9 @@ func (a Actions) FindMatch(action Action) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// search for a wildcard match
|
||||
for act := range a {
|
||||
if strings.HasSuffix(string(act), "*") && act.WildCardMatch(action) {
|
||||
if action.Match(act) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
175
auth/bucket_policy_actions_test.go
Normal file
175
auth/bucket_policy_actions_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAction_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action Action
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid exact action", GetObjectAction, false},
|
||||
{"valid all actions", AllActions, false},
|
||||
{"invalid prefix", "invalid:Action", true},
|
||||
{"unsupported action 1", "s3:Unsupported", true},
|
||||
{"unsupported action 2", "s3:HeadObject", true},
|
||||
{"valid wildcard match 1", "s3:Get*", false},
|
||||
{"valid wildcard match 2", "s3:*Object*", false},
|
||||
{"valid wildcard match 3", "s3:*Multipart*", false},
|
||||
{"any char match 1", "s3:Get?bject", false},
|
||||
{"any char match 2", "s3:Get??bject", true},
|
||||
{"any char match 3", "s3:???", true},
|
||||
{"mixed match 1", "s3:Get?*", false},
|
||||
{"mixed match 2", "s3:*Object?????", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.action.IsValid()
|
||||
if tt.wantErr {
|
||||
assert.EqualValues(t, policyErrInvalidAction, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_String(t *testing.T) {
|
||||
a := Action("s3:TestAction")
|
||||
assert.Equal(t, "s3:TestAction", a.String())
|
||||
}
|
||||
|
||||
func TestAction_Match(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action Action
|
||||
pattern Action
|
||||
want bool
|
||||
}{
|
||||
{"exact match", "s3:GetObject", "s3:GetObject", true},
|
||||
{"wildcard match", "s3:GetObject", "s3:Get*", true},
|
||||
{"wildcard mismatch", "s3:PutObject", "s3:Get*", false},
|
||||
{"any character match", "s3:Get1", "s3:Get?", true},
|
||||
{"any character mismatch", "s3:Get12", "s3:Get?", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.action.Match(tt.pattern)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_IsObjectAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action Action
|
||||
want *bool
|
||||
}{
|
||||
{"all actions", AllActions, nil},
|
||||
{"object action exact", GetObjectAction, getBoolPtr(true)},
|
||||
{"object action wildcard", "s3:Get*", getBoolPtr(true)},
|
||||
{"non object action", GetBucketAclAction, getBoolPtr(false)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.action.IsObjectAction()
|
||||
if tt.want == nil {
|
||||
assert.Nil(t, got)
|
||||
} else {
|
||||
assert.NotNil(t, got)
|
||||
assert.Equal(t, *tt.want, *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActions_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid slice", `["s3:GetObject","s3:PutObject"]`, false},
|
||||
{"empty slice", `[]`, true},
|
||||
{"invalid action in slice", `["s3:Invalid"]`, true},
|
||||
{"valid string", `"s3:GetObject"`, false},
|
||||
{"empty string", `""`, true},
|
||||
{"invalid string", `"s3:Invalid"`, true},
|
||||
{"invalid json", `{}`, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var a Actions
|
||||
err := json.Unmarshal([]byte(tt.input), &a)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActions_Add(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid add", "s3:GetObject", false},
|
||||
{"invalid add", "s3:InvalidAction", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := make(Actions)
|
||||
err := a.Add(tt.action)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
_, ok := a[Action(tt.action)]
|
||||
assert.True(t, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActions_FindMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actions Actions
|
||||
check Action
|
||||
want bool
|
||||
}{
|
||||
{"all actions present", Actions{AllActions: {}}, GetObjectAction, true},
|
||||
{"exact match", Actions{GetObjectAction: {}}, GetObjectAction, true},
|
||||
{"wildcard match", Actions{"s3:Get*": {}}, GetObjectAction, true},
|
||||
{"no match", Actions{"s3:Put*": {}}, GetObjectAction, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.actions.FindMatch(tt.check)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
57
auth/bucket_policy_effect_test.go
Normal file
57
auth/bucket_policy_effect_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBucketPolicyAccessType_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input BucketPolicyAccessType
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid allow",
|
||||
input: BucketPolicyAccessTypeAllow,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid deny",
|
||||
input: BucketPolicyAccessTypeDeny,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
input: BucketPolicyAccessType("InvalidValue"),
|
||||
wantErr: true,
|
||||
errMsg: "Invalid effect: InvalidValue",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.input.Validate()
|
||||
if tt.wantErr {
|
||||
assert.EqualError(t, err, tt.errMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,3 +121,10 @@ func (p Principals) Contains(userAccess string) bool {
|
||||
_, found := p[userAccess]
|
||||
return found
|
||||
}
|
||||
|
||||
// Bucket policy grants public access, if it contains
|
||||
// a wildcard match to all the users
|
||||
func (p Principals) isPublic() bool {
|
||||
_, ok := p["*"]
|
||||
return ok
|
||||
}
|
||||
|
||||
106
auth/bucket_policy_principals_test.go
Normal file
106
auth/bucket_policy_principals_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrincipals_Add(t *testing.T) {
|
||||
p := make(Principals)
|
||||
p.Add("user1")
|
||||
_, ok := p["user1"]
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestPrincipals_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want Principals
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid slice", `["user1","user2"]`, Principals{"user1": {}, "user2": {}}, false},
|
||||
{"empty slice", `[]`, nil, true},
|
||||
{"valid string", `"user1"`, Principals{"user1": {}}, false},
|
||||
{"empty string", `""`, nil, true},
|
||||
{"valid AWS object", `{"AWS":"user1"}`, Principals{"user1": {}}, false},
|
||||
{"empty AWS object", `{"AWS":""}`, nil, true},
|
||||
{"valid AWS array", `{"AWS":["user1","user2"]}`, Principals{"user1": {}, "user2": {}}, false},
|
||||
{"empty AWS array", `{"AWS":[]}`, nil, true},
|
||||
{"invalid json", `{invalid}`, nil, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var p Principals
|
||||
err := json.Unmarshal([]byte(tt.input), &p)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipals_ToSlice(t *testing.T) {
|
||||
p := Principals{"user1": {}, "user2": {}, "*": {}}
|
||||
got := p.ToSlice()
|
||||
assert.Contains(t, got, "user1")
|
||||
assert.Contains(t, got, "user2")
|
||||
assert.NotContains(t, got, "*")
|
||||
}
|
||||
|
||||
func TestPrincipals_Validate(t *testing.T) {
|
||||
iamSingle := NewIAMServiceSingle(Account{
|
||||
Access: "user1",
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
principals Principals
|
||||
mockIAM IAMService
|
||||
err error
|
||||
}{
|
||||
{"only wildcard", Principals{"*": {}}, iamSingle, nil},
|
||||
{"wildcard and user", Principals{"*": {}, "user1": {}}, iamSingle, policyErrInvalidPrincipal},
|
||||
{"accounts exist returns err", Principals{"user2": {}, "user3": {}}, iamSingle, policyErrInvalidPrincipal},
|
||||
{"accounts exist non-empty", Principals{"user1": {}}, iamSingle, nil},
|
||||
{"accounts valid", Principals{"user1": {}}, iamSingle, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.principals.Validate(tt.mockIAM)
|
||||
assert.EqualValues(t, tt.err, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipals_Contains(t *testing.T) {
|
||||
p := Principals{"user1": {}}
|
||||
assert.True(t, p.Contains("user1"))
|
||||
assert.False(t, p.Contains("user2"))
|
||||
|
||||
p = Principals{"*": {}}
|
||||
assert.True(t, p.Contains("anyuser"))
|
||||
}
|
||||
|
||||
func TestPrincipals_isPublic(t *testing.T) {
|
||||
assert.True(t, Principals{"*": {}}.isPublic())
|
||||
assert.False(t, Principals{"user1": {}}.isPublic())
|
||||
}
|
||||
@@ -110,35 +110,9 @@ func (r Resources) FindMatch(resource string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Match checks if the input string matches the given pattern with wildcards (`*`, `?`).
|
||||
// - `?` matches exactly one occurrence of any character.
|
||||
// - `*` matches arbitrary many (including zero) occurrences of any character.
|
||||
// Match matches the given input resource with the pattern
|
||||
func (r Resources) Match(pattern, input string) bool {
|
||||
pIdx, sIdx := 0, 0
|
||||
starIdx, matchIdx := -1, 0
|
||||
|
||||
for sIdx < len(input) {
|
||||
if pIdx < len(pattern) && (pattern[pIdx] == '?' || pattern[pIdx] == input[sIdx]) {
|
||||
sIdx++
|
||||
pIdx++
|
||||
} else if pIdx < len(pattern) && pattern[pIdx] == '*' {
|
||||
starIdx = pIdx
|
||||
matchIdx = sIdx
|
||||
pIdx++
|
||||
} else if starIdx != -1 {
|
||||
pIdx = starIdx + 1
|
||||
matchIdx++
|
||||
sIdx = matchIdx
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for pIdx < len(pattern) && pattern[pIdx] == '*' {
|
||||
pIdx++
|
||||
}
|
||||
|
||||
return pIdx == len(pattern)
|
||||
return matchPattern(pattern, input)
|
||||
}
|
||||
|
||||
// Checks the resource to have arn prefix and not starting with /
|
||||
|
||||
23
auth/iam.go
23
auth/iam.go
@@ -18,6 +18,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
type Role string
|
||||
@@ -57,10 +59,19 @@ type ListUserAccountsResult struct {
|
||||
// Mutable props, which could be changed when updating an IAM account
|
||||
type MutableProps struct {
|
||||
Secret *string `json:"secret"`
|
||||
Role Role `json:"role"`
|
||||
UserID *int `json:"userID"`
|
||||
GroupID *int `json:"groupID"`
|
||||
}
|
||||
|
||||
func (m MutableProps) Validate() error {
|
||||
if m.Role != "" && !m.Role.IsValid() {
|
||||
return s3err.GetAPIError(s3err.ErrAdminInvalidUserRole)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAcc(acc *Account, props MutableProps) {
|
||||
if props.Secret != nil {
|
||||
acc.Secret = *props.Secret
|
||||
@@ -71,6 +82,9 @@ func updateAcc(acc *Account, props MutableProps) {
|
||||
if props.UserID != nil {
|
||||
acc.UserID = *props.UserID
|
||||
}
|
||||
if props.Role != "" {
|
||||
acc.Role = props.Role
|
||||
}
|
||||
}
|
||||
|
||||
// IAMService is the interface for all IAM service implementations
|
||||
@@ -107,6 +121,7 @@ type Opts struct {
|
||||
LDAPGroupIdAtr string
|
||||
VaultEndpointURL string
|
||||
VaultSecretStoragePath string
|
||||
VaultAuthMethod string
|
||||
VaultMountPath string
|
||||
VaultRootToken string
|
||||
VaultRoleId string
|
||||
@@ -120,7 +135,6 @@ type Opts struct {
|
||||
S3Bucket string
|
||||
S3Endpoint string
|
||||
S3DisableSSlVerfiy bool
|
||||
S3Debug bool
|
||||
CacheDisable bool
|
||||
CacheTTL int
|
||||
CachePrune int
|
||||
@@ -129,7 +143,6 @@ type Opts struct {
|
||||
IpaUser string
|
||||
IpaPassword string
|
||||
IpaInsecure bool
|
||||
IpaDebug bool
|
||||
}
|
||||
|
||||
func New(o *Opts) (IAMService, error) {
|
||||
@@ -147,16 +160,16 @@ func New(o *Opts) (IAMService, error) {
|
||||
fmt.Printf("initializing LDAP IAM with %q\n", o.LDAPServerURL)
|
||||
case o.S3Endpoint != "":
|
||||
svc, err = NewS3(o.RootAccount, o.S3Access, o.S3Secret, o.S3Region, o.S3Bucket,
|
||||
o.S3Endpoint, o.S3DisableSSlVerfiy, o.S3Debug)
|
||||
o.S3Endpoint, o.S3DisableSSlVerfiy)
|
||||
fmt.Printf("initializing S3 IAM with '%v/%v'\n",
|
||||
o.S3Endpoint, o.S3Bucket)
|
||||
case o.VaultEndpointURL != "":
|
||||
svc, err = NewVaultIAMService(o.RootAccount, o.VaultEndpointURL, o.VaultSecretStoragePath,
|
||||
o.VaultMountPath, o.VaultRootToken, o.VaultRoleId, o.VaultRoleSecret,
|
||||
o.VaultAuthMethod, o.VaultMountPath, o.VaultRootToken, o.VaultRoleId, o.VaultRoleSecret,
|
||||
o.VaultServerCert, o.VaultClientCert, o.VaultClientCertKey)
|
||||
fmt.Printf("initializing Vault IAM with %q\n", o.VaultEndpointURL)
|
||||
case o.IpaHost != "":
|
||||
svc, err = NewIpaIAMService(o.RootAccount, o.IpaHost, o.IpaVaultName, o.IpaUser, o.IpaPassword, o.IpaInsecure, o.IpaDebug)
|
||||
svc, err = NewIpaIAMService(o.RootAccount, o.IpaHost, o.IpaVaultName, o.IpaUser, o.IpaPassword, o.IpaInsecure)
|
||||
fmt.Printf("initializing IPA IAM with %q\n", o.IpaHost)
|
||||
default:
|
||||
// if no iam options selected, default to the single user mode
|
||||
|
||||
@@ -290,93 +290,49 @@ func (s *IAMServiceInternal) readIAMData() ([]byte, error) {
|
||||
|
||||
func (s *IAMServiceInternal) storeIAM(update UpdateAcctFunc) error {
|
||||
// We are going to be racing with other running gateways without any
|
||||
// coordination. So the strategy here is to read the current file data.
|
||||
// If the file doesn't exist, then we assume someone else is currently
|
||||
// updating the file. So we just need to keep retrying. We also need
|
||||
// to make sure the data is consistent within a single update. So racing
|
||||
// writes to a file would possibly leave this in some invalid state.
|
||||
// We can get atomic updates with rename. If we read the data, update
|
||||
// the data, write to a temp file, then rename the tempfile back to the
|
||||
// data file. This should always result in a complete data image.
|
||||
// coordination. So the strategy here is to read the current file data,
|
||||
// update the data, write back out to a temp file, then rename the
|
||||
// temp file to the original file. This rename will replace the
|
||||
// original file with the new file. This is atomic and should always
|
||||
// allow for a consistent view of the data. There is a small
|
||||
// window where the file could be read and then updated by
|
||||
// another process. In this case any updates the other process did
|
||||
// will be lost. This is a limitation of the internal IAM service.
|
||||
// This should be rare, and even when it does happen should result
|
||||
// in a valid IAM file, just without the other process's updates.
|
||||
|
||||
// There is at least one unsolved failure mode here.
|
||||
// If a gateway removes the data file and then crashes, all other
|
||||
// gateways will retry forever thinking that the original will eventually
|
||||
// write the file.
|
||||
iamFname := filepath.Join(s.dir, iamFile)
|
||||
backupFname := filepath.Join(s.dir, iamBackupFile)
|
||||
|
||||
retries := 0
|
||||
fname := filepath.Join(s.dir, iamFile)
|
||||
b, err := os.ReadFile(iamFname)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("read iam file: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
b, err := os.ReadFile(fname)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// racing with someone else updating
|
||||
// keep retrying after backoff
|
||||
retries++
|
||||
if retries < maxretry {
|
||||
time.Sleep(backoff)
|
||||
continue
|
||||
}
|
||||
// save copy of data
|
||||
datacopy := make([]byte, len(b))
|
||||
copy(datacopy, b)
|
||||
|
||||
// we have been unsuccessful trying to read the iam file
|
||||
// so this must be the case where something happened and
|
||||
// the file did not get updated successfully, and probably
|
||||
// isn't going to be. The recovery procedure would be to
|
||||
// copy the backup file into place of the original.
|
||||
return fmt.Errorf("no iam file, needs backup recovery")
|
||||
}
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("read iam file: %w", err)
|
||||
}
|
||||
// make a backup copy in case something happens
|
||||
err = s.writeUsingTempFile(b, backupFname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write backup iam file: %w", err)
|
||||
}
|
||||
|
||||
// reset retries on successful read
|
||||
retries = 0
|
||||
b, err = update(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update iam data: %w", err)
|
||||
}
|
||||
|
||||
err = os.Remove(fname)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// racing with someone else updating
|
||||
// keep retrying after backoff
|
||||
time.Sleep(backoff)
|
||||
continue
|
||||
}
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove old iam file: %w", err)
|
||||
}
|
||||
|
||||
// save copy of data
|
||||
datacopy := make([]byte, len(b))
|
||||
copy(datacopy, b)
|
||||
|
||||
// make a backup copy in case we crash before update
|
||||
// this is after remove, so there is a small window something
|
||||
// can go wrong, but the remove should barrier other gateways
|
||||
// from trying to write backup at the same time. Only one
|
||||
// gateway will successfully remove the file.
|
||||
os.WriteFile(filepath.Join(s.dir, iamBackupFile), b, iamMode)
|
||||
|
||||
b, err = update(b)
|
||||
if err != nil {
|
||||
// update failed, try to write old data back out
|
||||
os.WriteFile(fname, datacopy, iamMode)
|
||||
return fmt.Errorf("update iam data: %w", err)
|
||||
}
|
||||
|
||||
err = s.writeTempFile(b)
|
||||
if err != nil {
|
||||
// update failed, try to write old data back out
|
||||
os.WriteFile(fname, datacopy, iamMode)
|
||||
return err
|
||||
}
|
||||
|
||||
break
|
||||
err = s.writeUsingTempFile(b, iamFname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write iam file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IAMServiceInternal) writeTempFile(b []byte) error {
|
||||
fname := filepath.Join(s.dir, iamFile)
|
||||
|
||||
func (s *IAMServiceInternal) writeUsingTempFile(b []byte, fname string) error {
|
||||
f, err := os.CreateTemp(s.dir, iamFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
@@ -384,6 +340,7 @@ func (s *IAMServiceInternal) writeTempFile(b []byte) error {
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
_, err = f.Write(b)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
|
||||
102
auth/iam_ipa.go
102
auth/iam_ipa.go
@@ -26,12 +26,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
)
|
||||
|
||||
const IpaVersion = "2.254"
|
||||
@@ -45,14 +50,12 @@ type IpaIAMService struct {
|
||||
username string
|
||||
password string
|
||||
kraTransportKey *rsa.PublicKey
|
||||
debug bool
|
||||
rootAcc Account
|
||||
}
|
||||
|
||||
var _ IAMService = &IpaIAMService{}
|
||||
|
||||
func NewIpaIAMService(rootAcc Account, host, vaultName, username, password string, isInsecure, debug bool) (*IpaIAMService, error) {
|
||||
|
||||
func NewIpaIAMService(rootAcc Account, host, vaultName, username, password string, isInsecure bool) (*IpaIAMService, error) {
|
||||
ipa := IpaIAMService{
|
||||
id: 0,
|
||||
version: IpaVersion,
|
||||
@@ -60,7 +63,6 @@ func NewIpaIAMService(rootAcc Account, host, vaultName, username, password strin
|
||||
vaultName: vaultName,
|
||||
username: username,
|
||||
password: password,
|
||||
debug: debug,
|
||||
rootAcc: rootAcc,
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
@@ -72,6 +74,7 @@ func NewIpaIAMService(rootAcc Account, host, vaultName, username, password strin
|
||||
mTLSConfig := &tls.Config{InsecureSkipVerify: isInsecure}
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: mTLSConfig,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
ipa.client = http.Client{Jar: jar, Transport: tr}
|
||||
|
||||
@@ -102,13 +105,7 @@ func NewIpaIAMService(rootAcc Account, host, vaultName, username, password strin
|
||||
|
||||
ipa.kraTransportKey = cert.PublicKey.(*rsa.PublicKey)
|
||||
|
||||
isSupported := false
|
||||
for _, algo := range vaultConfig.Wrapping_supported_algorithms {
|
||||
if algo == "aes-128-cbc" {
|
||||
isSupported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
isSupported := slices.Contains(vaultConfig.Wrapping_supported_algorithms, "aes-128-cbc")
|
||||
|
||||
if !isSupported {
|
||||
return nil,
|
||||
@@ -226,6 +223,8 @@ func (ipa *IpaIAMService) Shutdown() error {
|
||||
|
||||
// Implementation
|
||||
|
||||
const requestRetries = 3
|
||||
|
||||
func (ipa *IpaIAMService) login() error {
|
||||
form := url.Values{}
|
||||
form.Set("user", ipa.username)
|
||||
@@ -242,17 +241,33 @@ func (ipa *IpaIAMService) login() error {
|
||||
req.Header.Set("referer", fmt.Sprintf("%s/ipa", ipa.host))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := ipa.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
var resp *http.Response
|
||||
for i := range requestRetries {
|
||||
resp, err = ipa.client.Do(req)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
// Check for transient network errors
|
||||
if isRetryable(err) {
|
||||
time.Sleep(time.Second * time.Duration(i+1))
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("login POST to %s failed: %w", req.URL, err)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("login POST to %s failed after retries: %w",
|
||||
req.URL, err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return errors.New("cannot login to FreeIPA: invalid credentials")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("cannot login to FreeIPA: status code %d", resp.StatusCode)
|
||||
return fmt.Errorf("cannot login to FreeIPA: status code %d",
|
||||
resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -295,17 +310,34 @@ func (ipa *IpaIAMService) rpcInternal(req rpcRequest) (rpcResponse, error) {
|
||||
return rpcResponse{}, err
|
||||
}
|
||||
|
||||
ipa.log(fmt.Sprintf("%v", req))
|
||||
debuglogger.IAMLogf("IPA request: %v", req)
|
||||
httpReq.Header.Set("referer", fmt.Sprintf("%s/ipa", ipa.host))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httpResp, err := ipa.client.Do(httpReq)
|
||||
var httpResp *http.Response
|
||||
for i := range requestRetries {
|
||||
httpResp, err = ipa.client.Do(httpReq)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
// Check for transient network errors
|
||||
if isRetryable(err) {
|
||||
time.Sleep(time.Second * time.Duration(i+1))
|
||||
continue
|
||||
}
|
||||
return rpcResponse{}, fmt.Errorf("ipa request to %s failed: %w",
|
||||
httpReq.URL, err)
|
||||
}
|
||||
if err != nil {
|
||||
return rpcResponse{}, err
|
||||
return rpcResponse{},
|
||||
fmt.Errorf("ipa request to %s failed after retries: %w",
|
||||
httpReq.URL, err)
|
||||
}
|
||||
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
bytes, err := io.ReadAll(httpResp.Body)
|
||||
ipa.log(string(bytes))
|
||||
debuglogger.IAMLogf("IPA response (%v): %v", err, string(bytes))
|
||||
if err != nil {
|
||||
return rpcResponse{}, err
|
||||
}
|
||||
@@ -338,6 +370,30 @@ func (ipa *IpaIAMService) rpcInternal(req rpcRequest) (rpcResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
return true
|
||||
}
|
||||
|
||||
if err, ok := err.(net.Error); ok && err.Timeout() {
|
||||
return true
|
||||
}
|
||||
|
||||
if opErr, ok := err.(*net.OpError); ok {
|
||||
if sysErr, ok := opErr.Err.(*syscall.Errno); ok {
|
||||
if *sysErr == syscall.ECONNRESET {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (ipa *IpaIAMService) newRequest(method string, args []string, dict map[string]any) (rpcRequest, error) {
|
||||
|
||||
id := ipa.id
|
||||
@@ -438,9 +494,3 @@ func (b *Base64Encoded) UnmarshalJSON(data []byte) error {
|
||||
*b, err = base64.StdEncoding.DecodeString(intermediate)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ipa *IpaIAMService) log(msg string) {
|
||||
if ipa.debug {
|
||||
log.Println(msg)
|
||||
}
|
||||
}
|
||||
|
||||
104
auth/iam_ldap.go
104
auth/iam_ldap.go
@@ -18,8 +18,11 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
)
|
||||
|
||||
type LdapIAMService struct {
|
||||
@@ -32,6 +35,10 @@ type LdapIAMService struct {
|
||||
groupIdAtr string
|
||||
userIdAtr string
|
||||
rootAcc Account
|
||||
url string
|
||||
bindDN string
|
||||
pass string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ IAMService = &LdapIAMService{}
|
||||
@@ -60,9 +67,45 @@ func NewLDAPService(rootAcc Account, url, bindDN, pass, queryBase, accAtr, secAt
|
||||
userIdAtr: userIdAtr,
|
||||
groupIdAtr: groupIdAtr,
|
||||
rootAcc: rootAcc,
|
||||
url: url,
|
||||
bindDN: bindDN,
|
||||
pass: pass,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ld *LdapIAMService) reconnect() error {
|
||||
ld.conn.Close()
|
||||
|
||||
conn, err := ldap.DialURL(ld.url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconnect to LDAP server: %w", err)
|
||||
}
|
||||
|
||||
err = conn.Bind(ld.bindDN, ld.pass)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("failed to bind to LDAP server on reconnect: %w", err)
|
||||
}
|
||||
ld.conn = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ld *LdapIAMService) execute(f func(*ldap.Conn) error) error {
|
||||
ld.mu.Lock()
|
||||
defer ld.mu.Unlock()
|
||||
|
||||
err := f(ld.conn)
|
||||
if err != nil {
|
||||
if e, ok := err.(*ldap.Error); ok && e.ResultCode == ldap.ErrorNetwork {
|
||||
if reconnErr := ld.reconnect(); reconnErr != nil {
|
||||
return reconnErr
|
||||
}
|
||||
return f(ld.conn)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (ld *LdapIAMService) CreateAccount(account Account) error {
|
||||
if ld.rootAcc.Access == account.Access {
|
||||
return ErrUserExists
|
||||
@@ -75,7 +118,9 @@ func (ld *LdapIAMService) CreateAccount(account Account) error {
|
||||
userEntry.Attribute(ld.groupIdAtr, []string{fmt.Sprint(account.GroupID)})
|
||||
userEntry.Attribute(ld.userIdAtr, []string{fmt.Sprint(account.UserID)})
|
||||
|
||||
err := ld.conn.Add(userEntry)
|
||||
err := ld.execute(func(c *ldap.Conn) error {
|
||||
return c.Add(userEntry)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error adding an entry: %w", err)
|
||||
}
|
||||
@@ -83,10 +128,22 @@ func (ld *LdapIAMService) CreateAccount(account Account) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ld *LdapIAMService) buildSearchFilter(access string) string {
|
||||
var searchFilter strings.Builder
|
||||
for _, el := range ld.objClasses {
|
||||
searchFilter.WriteString(fmt.Sprintf("(objectClass=%v)", el))
|
||||
}
|
||||
if access != "" {
|
||||
searchFilter.WriteString(fmt.Sprintf("(%v=%v)", ld.accessAtr, access))
|
||||
}
|
||||
return fmt.Sprintf("(&%v)", searchFilter.String())
|
||||
}
|
||||
|
||||
func (ld *LdapIAMService) GetUserAccount(access string) (Account, error) {
|
||||
if access == ld.rootAcc.Access {
|
||||
return ld.rootAcc, nil
|
||||
}
|
||||
var result *ldap.SearchResult
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
ld.queryBase,
|
||||
ldap.ScopeWholeSubtree,
|
||||
@@ -94,12 +151,27 @@ func (ld *LdapIAMService) GetUserAccount(access string) (Account, error) {
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
fmt.Sprintf("(%v=%v)", ld.accessAtr, access),
|
||||
ld.buildSearchFilter(access),
|
||||
[]string{ld.accessAtr, ld.secretAtr, ld.roleAtr, ld.userIdAtr, ld.groupIdAtr},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := ld.conn.Search(searchRequest)
|
||||
if debuglogger.IsIAMDebugEnabled() {
|
||||
debuglogger.IAMLogf("LDAP Search Request")
|
||||
debuglogger.IAMLogf(spew.Sdump(searchRequest))
|
||||
}
|
||||
|
||||
err := ld.execute(func(c *ldap.Conn) error {
|
||||
var err error
|
||||
result, err = c.Search(searchRequest)
|
||||
return err
|
||||
})
|
||||
|
||||
if debuglogger.IsIAMDebugEnabled() {
|
||||
debuglogger.IAMLogf("LDAP Search Result")
|
||||
debuglogger.IAMLogf(spew.Sdump(result))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
@@ -139,8 +211,13 @@ func (ld *LdapIAMService) UpdateUserAccount(access string, props MutableProps) e
|
||||
if props.UserID != nil {
|
||||
req.Replace(ld.userIdAtr, []string{fmt.Sprint(*props.UserID)})
|
||||
}
|
||||
if props.Role != "" {
|
||||
req.Replace(ld.roleAtr, []string{string(props.Role)})
|
||||
}
|
||||
|
||||
err := ld.conn.Modify(req)
|
||||
err := ld.execute(func(c *ldap.Conn) error {
|
||||
return c.Modify(req)
|
||||
})
|
||||
//TODO: Handle non existing user case
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -151,7 +228,9 @@ func (ld *LdapIAMService) UpdateUserAccount(access string, props MutableProps) e
|
||||
func (ld *LdapIAMService) DeleteUserAccount(access string) error {
|
||||
delReq := ldap.NewDelRequest(fmt.Sprintf("%v=%v, %v", ld.accessAtr, access, ld.queryBase), nil)
|
||||
|
||||
err := ld.conn.Del(delReq)
|
||||
err := ld.execute(func(c *ldap.Conn) error {
|
||||
return c.Del(delReq)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -160,10 +239,7 @@ func (ld *LdapIAMService) DeleteUserAccount(access string) error {
|
||||
}
|
||||
|
||||
func (ld *LdapIAMService) ListUserAccounts() ([]Account, error) {
|
||||
searchFilter := ""
|
||||
for _, el := range ld.objClasses {
|
||||
searchFilter += fmt.Sprintf("(objectClass=%v)", el)
|
||||
}
|
||||
var resp *ldap.SearchResult
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
ld.queryBase,
|
||||
ldap.ScopeWholeSubtree,
|
||||
@@ -171,12 +247,16 @@ func (ld *LdapIAMService) ListUserAccounts() ([]Account, error) {
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
fmt.Sprintf("(&%v)", searchFilter),
|
||||
ld.buildSearchFilter(""),
|
||||
[]string{ld.accessAtr, ld.secretAtr, ld.roleAtr, ld.groupIdAtr, ld.userIdAtr},
|
||||
nil,
|
||||
)
|
||||
|
||||
resp, err := ld.conn.Search(searchRequest)
|
||||
err := ld.execute(func(c *ldap.Conn) error {
|
||||
var err error
|
||||
resp, err = c.Search(searchRequest)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -207,5 +287,7 @@ func (ld *LdapIAMService) ListUserAccounts() ([]Account, error) {
|
||||
|
||||
// Shutdown graceful termination of service
|
||||
func (ld *LdapIAMService) Shutdown() error {
|
||||
ld.mu.Lock()
|
||||
defer ld.mu.Unlock()
|
||||
return ld.conn.Close()
|
||||
}
|
||||
|
||||
56
auth/iam_ldap_test.go
Normal file
56
auth/iam_ldap_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLdapIAMService_BuildSearchFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
objClasses []string
|
||||
accessAtr string
|
||||
access string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single object class with access",
|
||||
objClasses: []string{"inetOrgPerson"},
|
||||
accessAtr: "uid",
|
||||
access: "testuser",
|
||||
expected: "(&(objectClass=inetOrgPerson)(uid=testuser))",
|
||||
},
|
||||
{
|
||||
name: "single object class without access",
|
||||
objClasses: []string{"inetOrgPerson"},
|
||||
accessAtr: "uid",
|
||||
access: "",
|
||||
expected: "(&(objectClass=inetOrgPerson))",
|
||||
},
|
||||
{
|
||||
name: "multiple object classes with access",
|
||||
objClasses: []string{"inetOrgPerson", "organizationalPerson"},
|
||||
accessAtr: "cn",
|
||||
access: "john.doe",
|
||||
expected: "(&(objectClass=inetOrgPerson)(objectClass=organizationalPerson)(cn=john.doe))",
|
||||
},
|
||||
{
|
||||
name: "multiple object classes without access",
|
||||
objClasses: []string{"inetOrgPerson", "organizationalPerson", "person"},
|
||||
accessAtr: "cn",
|
||||
access: "",
|
||||
expected: "(&(objectClass=inetOrgPerson)(objectClass=organizationalPerson)(objectClass=person))",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ld := &LdapIAMService{
|
||||
objClasses: tt.objClasses,
|
||||
accessAtr: tt.accessAtr,
|
||||
}
|
||||
|
||||
result := ld.buildSearchFilter(tt.access)
|
||||
if result != tt.expected {
|
||||
t.Errorf("BuildSearchFilter() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
)
|
||||
|
||||
// IAMServiceS3 stores user accounts in an S3 object
|
||||
@@ -56,14 +57,13 @@ type IAMServiceS3 struct {
|
||||
bucket string
|
||||
endpoint string
|
||||
sslSkipVerify bool
|
||||
debug bool
|
||||
rootAcc Account
|
||||
client *s3.Client
|
||||
}
|
||||
|
||||
var _ IAMService = &IAMServiceS3{}
|
||||
|
||||
func NewS3(rootAcc Account, access, secret, region, bucket, endpoint string, sslSkipVerify, debug bool) (*IAMServiceS3, error) {
|
||||
func NewS3(rootAcc Account, access, secret, region, bucket, endpoint string, sslSkipVerify bool) (*IAMServiceS3, error) {
|
||||
if access == "" {
|
||||
return nil, fmt.Errorf("must provide s3 IAM service access key")
|
||||
}
|
||||
@@ -87,7 +87,6 @@ func NewS3(rootAcc Account, access, secret, region, bucket, endpoint string, ssl
|
||||
bucket: bucket,
|
||||
endpoint: endpoint,
|
||||
sslSkipVerify: sslSkipVerify,
|
||||
debug: debug,
|
||||
rootAcc: rootAcc,
|
||||
}
|
||||
|
||||
@@ -235,7 +234,7 @@ func (s *IAMServiceS3) getConfig() (aws.Config, error) {
|
||||
config.WithHTTPClient(client),
|
||||
}
|
||||
|
||||
if s.debug {
|
||||
if debuglogger.IsIAMDebugEnabled() {
|
||||
opts = append(opts,
|
||||
config.WithClientLogMode(aws.LogSigning|aws.LogRetries|aws.LogRequest|aws.LogResponse|aws.LogRequestEventMessage|aws.LogResponseEventMessage))
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,20 +27,25 @@ import (
|
||||
"github.com/hashicorp/vault-client-go/schema"
|
||||
)
|
||||
|
||||
const requestTimeout = 10 * time.Second
|
||||
|
||||
type VaultIAMService struct {
|
||||
client *vault.Client
|
||||
reqOpts []vault.RequestOption
|
||||
authReqOpts []vault.RequestOption
|
||||
kvReqOpts []vault.RequestOption
|
||||
secretStoragePath string
|
||||
rootAcc Account
|
||||
creds schema.AppRoleLoginRequest
|
||||
}
|
||||
|
||||
var _ IAMService = &VaultIAMService{}
|
||||
|
||||
func NewVaultIAMService(rootAcc Account, endpoint, secretStoragePath, mountPath, rootToken, roleID, roleSecret, serverCert, clientCert, clientCertKey string) (IAMService, error) {
|
||||
func NewVaultIAMService(rootAcc Account, endpoint, secretStoragePath,
|
||||
authMethod, mountPath, rootToken, roleID, roleSecret, serverCert,
|
||||
clientCert, clientCertKey string) (IAMService, error) {
|
||||
opts := []vault.ClientOption{
|
||||
vault.WithAddress(endpoint),
|
||||
// set request timeout to 10 secs
|
||||
vault.WithRequestTimeout(10 * time.Second),
|
||||
vault.WithRequestTimeout(requestTimeout),
|
||||
}
|
||||
if serverCert != "" {
|
||||
tls := vault.TLSConfiguration{}
|
||||
@@ -62,10 +68,21 @@ func NewVaultIAMService(rootAcc Account, endpoint, secretStoragePath, mountPath,
|
||||
return nil, fmt.Errorf("init vault client: %w", err)
|
||||
}
|
||||
|
||||
reqOpts := []vault.RequestOption{}
|
||||
// if mount path is not specified, it defaults to "approle"
|
||||
authReqOpts := []vault.RequestOption{}
|
||||
// if auth method path is not specified, it defaults to "approle"
|
||||
if authMethod != "" {
|
||||
authReqOpts = append(authReqOpts, vault.WithMountPath(authMethod))
|
||||
}
|
||||
|
||||
kvReqOpts := []vault.RequestOption{}
|
||||
// if mount path is not specified, it defaults to "kv-v2"
|
||||
if mountPath != "" {
|
||||
reqOpts = append(reqOpts, vault.WithMountPath(mountPath))
|
||||
kvReqOpts = append(kvReqOpts, vault.WithMountPath(mountPath))
|
||||
}
|
||||
|
||||
creds := schema.AppRoleLoginRequest{
|
||||
RoleId: roleID,
|
||||
SecretId: roleSecret,
|
||||
}
|
||||
|
||||
// Authentication
|
||||
@@ -80,12 +97,8 @@ func NewVaultIAMService(rootAcc Account, endpoint, secretStoragePath, mountPath,
|
||||
return nil, fmt.Errorf("role id and role secret must both be specified")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
resp, err := client.Auth.AppRoleLogin(ctx, schema.AppRoleLoginRequest{
|
||||
RoleId: roleID,
|
||||
SecretId: roleSecret,
|
||||
}, reqOpts...)
|
||||
cancel()
|
||||
resp, err := client.Auth.AppRoleLogin(context.Background(),
|
||||
creds, authReqOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("approle authentication failure: %w", err)
|
||||
}
|
||||
@@ -99,33 +112,77 @@ func NewVaultIAMService(rootAcc Account, endpoint, secretStoragePath, mountPath,
|
||||
|
||||
return &VaultIAMService{
|
||||
client: client,
|
||||
reqOpts: reqOpts,
|
||||
authReqOpts: authReqOpts,
|
||||
kvReqOpts: kvReqOpts,
|
||||
secretStoragePath: secretStoragePath,
|
||||
rootAcc: rootAcc,
|
||||
creds: creds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (vt *VaultIAMService) reAuthIfNeeded(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Vault returns 403 for expired/revoked tokens
|
||||
// pass all other errors back unchanged
|
||||
if !vault.IsErrorStatus(err, http.StatusForbidden) {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, authErr := vt.client.Auth.AppRoleLogin(context.Background(),
|
||||
vt.creds, vt.authReqOpts...)
|
||||
if authErr != nil {
|
||||
return fmt.Errorf("vault re-authentication failure: %w", authErr)
|
||||
}
|
||||
if err := vt.client.SetToken(resp.Auth.ClientToken); err != nil {
|
||||
return fmt.Errorf("vault re-authentication set token failure: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vt *VaultIAMService) CreateAccount(account Account) error {
|
||||
if vt.rootAcc.Access == account.Access {
|
||||
return ErrUserExists
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, err := vt.client.Secrets.KvV2Write(ctx, vt.secretStoragePath+"/"+account.Access, schema.KvV2WriteRequest{
|
||||
Data: map[string]any{
|
||||
account.Access: account,
|
||||
},
|
||||
Options: map[string]interface{}{
|
||||
"cas": 0,
|
||||
},
|
||||
}, vt.reqOpts...)
|
||||
cancel()
|
||||
_, err := vt.client.Secrets.KvV2Write(context.Background(),
|
||||
vt.secretStoragePath+"/"+account.Access, schema.KvV2WriteRequest{
|
||||
Data: map[string]any{
|
||||
account.Access: account,
|
||||
},
|
||||
Options: map[string]any{
|
||||
"cas": 0,
|
||||
},
|
||||
}, vt.kvReqOpts...)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "check-and-set") {
|
||||
return ErrUserExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
reauthErr := vt.reAuthIfNeeded(err)
|
||||
if reauthErr != nil {
|
||||
return reauthErr
|
||||
}
|
||||
// retry once after re-auth
|
||||
_, err = vt.client.Secrets.KvV2Write(context.Background(),
|
||||
vt.secretStoragePath+"/"+account.Access, schema.KvV2WriteRequest{
|
||||
Data: map[string]any{
|
||||
account.Access: account,
|
||||
},
|
||||
Options: map[string]any{
|
||||
"cas": 0,
|
||||
},
|
||||
}, vt.kvReqOpts...)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "check-and-set") {
|
||||
return ErrUserExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -133,66 +190,84 @@ func (vt *VaultIAMService) GetUserAccount(access string) (Account, error) {
|
||||
if vt.rootAcc.Access == access {
|
||||
return vt.rootAcc, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
resp, err := vt.client.Secrets.KvV2Read(ctx, vt.secretStoragePath+"/"+access, vt.reqOpts...)
|
||||
cancel()
|
||||
resp, err := vt.client.Secrets.KvV2Read(context.Background(),
|
||||
vt.secretStoragePath+"/"+access, vt.kvReqOpts...)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
reauthErr := vt.reAuthIfNeeded(err)
|
||||
if reauthErr != nil {
|
||||
return Account{}, reauthErr
|
||||
}
|
||||
// retry once after re-auth
|
||||
resp, err = vt.client.Secrets.KvV2Read(context.Background(),
|
||||
vt.secretStoragePath+"/"+access, vt.kvReqOpts...)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
}
|
||||
|
||||
acc, err := parseVaultUserAccount(resp.Data.Data, access)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (vt *VaultIAMService) UpdateUserAccount(access string, props MutableProps) error {
|
||||
//TODO: We need something like a transaction here ?
|
||||
acc, err := vt.GetUserAccount(access)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateAcc(&acc, props)
|
||||
|
||||
err = vt.DeleteUserAccount(access)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = vt.CreateAccount(acc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vt *VaultIAMService) DeleteUserAccount(access string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, err := vt.client.Secrets.KvV2DeleteMetadataAndAllVersions(ctx, vt.secretStoragePath+"/"+access, vt.reqOpts...)
|
||||
cancel()
|
||||
_, err := vt.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(),
|
||||
vt.secretStoragePath+"/"+access, vt.kvReqOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
reauthErr := vt.reAuthIfNeeded(err)
|
||||
if reauthErr != nil {
|
||||
return reauthErr
|
||||
}
|
||||
// retry once after re-auth
|
||||
_, err = vt.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(),
|
||||
vt.secretStoragePath+"/"+access, vt.kvReqOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vt *VaultIAMService) ListUserAccounts() ([]Account, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
resp, err := vt.client.Secrets.KvV2List(ctx, vt.secretStoragePath, vt.reqOpts...)
|
||||
cancel()
|
||||
resp, err := vt.client.Secrets.KvV2List(context.Background(),
|
||||
vt.secretStoragePath, vt.kvReqOpts...)
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, 404) {
|
||||
return []Account{}, nil
|
||||
reauthErr := vt.reAuthIfNeeded(err)
|
||||
if reauthErr != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return []Account{}, nil
|
||||
}
|
||||
return nil, reauthErr
|
||||
}
|
||||
// retry once after re-auth
|
||||
resp, err = vt.client.Secrets.KvV2List(context.Background(),
|
||||
vt.secretStoragePath, vt.kvReqOpts...)
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return []Account{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accs := []Account{}
|
||||
|
||||
for _, acss := range resp.Data.Keys {
|
||||
acc, err := vt.GetUserAccount(acss)
|
||||
if err != nil {
|
||||
@@ -200,7 +275,6 @@ func (vt *VaultIAMService) ListUserAccounts() ([]Account, error) {
|
||||
}
|
||||
accs = append(accs, acc)
|
||||
}
|
||||
|
||||
return accs, nil
|
||||
}
|
||||
|
||||
@@ -211,8 +285,8 @@ func (vt *VaultIAMService) Shutdown() error {
|
||||
|
||||
var errInvalidUser error = errors.New("invalid user account entry in secrets engine")
|
||||
|
||||
func parseVaultUserAccount(data map[string]interface{}, access string) (acc Account, err error) {
|
||||
usrAcc, ok := data[access].(map[string]interface{})
|
||||
func parseVaultUserAccount(data map[string]any, access string) (acc Account, err error) {
|
||||
usrAcc, ok := data[access].(map[string]any)
|
||||
if !ok {
|
||||
return acc, errInvalidUser
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func ParseObjectLegalHoldOutput(status *bool) *s3response.GetObjectLegalHoldResu
|
||||
}
|
||||
}
|
||||
|
||||
func CheckObjectAccess(ctx context.Context, bucket, userAccess string, objects []types.ObjectIdentifier, bypass bool, be backend.Backend) error {
|
||||
func CheckObjectAccess(ctx context.Context, bucket, userAccess string, objects []types.ObjectIdentifier, bypass, isBucketPublic bool, be backend.Backend) error {
|
||||
data, err := be.GetObjectLockConfiguration(ctx, bucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound)) {
|
||||
@@ -211,7 +211,11 @@ func CheckObjectAccess(ctx context.Context, bucket, userAccess string, objects [
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = VerifyBucketPolicy(policy, userAccess, bucket, key, BypassGovernanceRetentionAction)
|
||||
if isBucketPublic {
|
||||
err = VerifyPublicBucketPolicy(policy, bucket, key, BypassGovernanceRetentionAction)
|
||||
} else {
|
||||
err = VerifyBucketPolicy(policy, userAccess, bucket, key, BypassGovernanceRetentionAction)
|
||||
}
|
||||
if err != nil {
|
||||
return s3err.GetAPIError(s3err.ErrObjectLocked)
|
||||
}
|
||||
@@ -254,7 +258,11 @@ func CheckObjectAccess(ctx context.Context, bucket, userAccess string, objects [
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = VerifyBucketPolicy(policy, userAccess, bucket, key, BypassGovernanceRetentionAction)
|
||||
if isBucketPublic {
|
||||
err = VerifyPublicBucketPolicy(policy, bucket, key, BypassGovernanceRetentionAction)
|
||||
} else {
|
||||
err = VerifyBucketPolicy(policy, userAccess, bucket, key, BypassGovernanceRetentionAction)
|
||||
}
|
||||
if err != nil {
|
||||
return s3err.GetAPIError(s3err.ErrObjectLocked)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ func azErrToS3err(azErr *azcore.ResponseError) s3err.APIError {
|
||||
case "BlobNotFound":
|
||||
return s3err.GetAPIError(s3err.ErrNoSuchKey)
|
||||
case "TagsTooLarge":
|
||||
return s3err.GetAPIError(s3err.ErrInvalidTag)
|
||||
return s3err.GetAPIError(s3err.ErrInvalidTagValue)
|
||||
case "Requested Range Not Satisfiable":
|
||||
return s3err.GetAPIError(s3err.ErrInvalidRange)
|
||||
}
|
||||
|
||||
@@ -46,13 +46,13 @@ type Backend interface {
|
||||
PutBucketOwnershipControls(_ context.Context, bucket string, ownership types.ObjectOwnership) error
|
||||
GetBucketOwnershipControls(_ context.Context, bucket string) (types.ObjectOwnership, error)
|
||||
DeleteBucketOwnershipControls(_ context.Context, bucket string) error
|
||||
PutBucketCors(context.Context, []byte) error
|
||||
PutBucketCors(_ context.Context, bucket string, cors []byte) error
|
||||
GetBucketCors(_ context.Context, bucket string) ([]byte, error)
|
||||
DeleteBucketCors(_ context.Context, bucket string) error
|
||||
|
||||
// multipart operations
|
||||
CreateMultipartUpload(context.Context, s3response.CreateMultipartUploadInput) (s3response.InitiateMultipartUploadResult, error)
|
||||
CompleteMultipartUpload(context.Context, *s3.CompleteMultipartUploadInput) (*s3.CompleteMultipartUploadOutput, error)
|
||||
CompleteMultipartUpload(context.Context, *s3.CompleteMultipartUploadInput) (_ s3response.CompleteMultipartUploadResult, versionid string, _ error)
|
||||
AbortMultipartUpload(context.Context, *s3.AbortMultipartUploadInput) error
|
||||
ListMultipartUploads(context.Context, *s3.ListMultipartUploadsInput) (s3response.ListMultipartUploadsResult, error)
|
||||
ListParts(context.Context, *s3.ListPartsInput) (s3response.ListPartsResult, error)
|
||||
@@ -65,7 +65,7 @@ type Backend interface {
|
||||
GetObject(context.Context, *s3.GetObjectInput) (*s3.GetObjectOutput, error)
|
||||
GetObjectAcl(context.Context, *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error)
|
||||
GetObjectAttributes(context.Context, *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error)
|
||||
CopyObject(context.Context, s3response.CopyObjectInput) (*s3.CopyObjectOutput, error)
|
||||
CopyObject(context.Context, s3response.CopyObjectInput) (s3response.CopyObjectOutput, error)
|
||||
ListObjects(context.Context, *s3.ListObjectsInput) (s3response.ListObjectsResult, error)
|
||||
ListObjectsV2(context.Context, *s3.ListObjectsV2Input) (s3response.ListObjectsV2Result, error)
|
||||
DeleteObject(context.Context, *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error)
|
||||
@@ -96,7 +96,7 @@ type Backend interface {
|
||||
GetObjectLegalHold(_ context.Context, bucket, object, versionId string) (*bool, error)
|
||||
|
||||
// non AWS actions
|
||||
ChangeBucketOwner(_ context.Context, bucket string, acl []byte) error
|
||||
ChangeBucketOwner(_ context.Context, bucket, owner string) error
|
||||
ListBucketsAndOwners(context.Context) ([]s3response.Bucket, error)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func (BackendUnsupported) GetBucketOwnershipControls(_ context.Context, bucket s
|
||||
func (BackendUnsupported) DeleteBucketOwnershipControls(_ context.Context, bucket string) error {
|
||||
return s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) PutBucketCors(context.Context, []byte) error {
|
||||
func (BackendUnsupported) PutBucketCors(context.Context, string, []byte) error {
|
||||
return s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) GetBucketCors(_ context.Context, bucket string) ([]byte, error) {
|
||||
@@ -166,8 +166,8 @@ func (BackendUnsupported) DeleteBucketCors(_ context.Context, bucket string) err
|
||||
func (BackendUnsupported) CreateMultipartUpload(context.Context, s3response.CreateMultipartUploadInput) (s3response.InitiateMultipartUploadResult, error) {
|
||||
return s3response.InitiateMultipartUploadResult{}, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) CompleteMultipartUpload(context.Context, *s3.CompleteMultipartUploadInput) (*s3.CompleteMultipartUploadOutput, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
func (BackendUnsupported) CompleteMultipartUpload(context.Context, *s3.CompleteMultipartUploadInput) (s3response.CompleteMultipartUploadResult, string, error) {
|
||||
return s3response.CompleteMultipartUploadResult{}, "", s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) AbortMultipartUpload(context.Context, *s3.AbortMultipartUploadInput) error {
|
||||
return s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
@@ -200,8 +200,8 @@ func (BackendUnsupported) GetObjectAcl(context.Context, *s3.GetObjectAclInput) (
|
||||
func (BackendUnsupported) GetObjectAttributes(context.Context, *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
|
||||
return s3response.GetObjectAttributesResponse{}, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) CopyObject(context.Context, s3response.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
func (BackendUnsupported) CopyObject(context.Context, s3response.CopyObjectInput) (s3response.CopyObjectOutput, error) {
|
||||
return s3response.CopyObjectOutput{}, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) ListObjects(context.Context, *s3.ListObjectsInput) (s3response.ListObjectsResult, error) {
|
||||
return s3response.ListObjectsResult{}, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
@@ -280,7 +280,7 @@ func (BackendUnsupported) GetObjectLegalHold(_ context.Context, bucket, object,
|
||||
return nil, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
|
||||
func (BackendUnsupported) ChangeBucketOwner(_ context.Context, bucket string, acl []byte) error {
|
||||
func (BackendUnsupported) ChangeBucketOwner(_ context.Context, bucket, owner string) error {
|
||||
return s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) ListBucketsAndOwners(context.Context) ([]s3response.Bucket, error) {
|
||||
|
||||
@@ -17,11 +17,18 @@ package backend
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
@@ -81,58 +88,90 @@ func TrimEtag(etag *string) *string {
|
||||
var (
|
||||
errInvalidRange = s3err.GetAPIError(s3err.ErrInvalidRange)
|
||||
errInvalidCopySourceRange = s3err.GetAPIError(s3err.ErrInvalidCopySourceRange)
|
||||
errPreconditionFailed = s3err.GetAPIError(s3err.ErrPreconditionFailed)
|
||||
errNotModified = s3err.GetAPIError(s3err.ErrNotModified)
|
||||
)
|
||||
|
||||
// ParseGetObjectRange parses input range header and returns startoffset, length, isValid
|
||||
// ParseObjectRange parses input range header and returns startoffset, length, isValid
|
||||
// and error. If no endoffset specified, then length is set to the object size
|
||||
// for invalid inputs, it returns no error, but isValid=false
|
||||
// `InvalidRange` error is returnd, only if startoffset is greater than the object size
|
||||
func ParseGetObjectRange(size int64, acceptRange string) (int64, int64, bool, error) {
|
||||
func ParseObjectRange(size int64, acceptRange string) (int64, int64, bool, error) {
|
||||
// Return full object (invalid range, no error) if header empty
|
||||
if acceptRange == "" {
|
||||
return 0, size, false, nil
|
||||
}
|
||||
|
||||
rangeKv := strings.Split(acceptRange, "=")
|
||||
|
||||
if len(rangeKv) != 2 {
|
||||
return 0, size, false, nil
|
||||
}
|
||||
|
||||
if rangeKv[0] != "bytes" {
|
||||
if rangeKv[0] != "bytes" { // unsupported unit -> ignore
|
||||
return 0, size, false, nil
|
||||
}
|
||||
|
||||
bRange := strings.Split(rangeKv[1], "-")
|
||||
if len(bRange) != 2 {
|
||||
if len(bRange) != 2 { // malformed / multi-range
|
||||
return 0, size, false, nil
|
||||
}
|
||||
|
||||
startOffset, err := strconv.ParseInt(bRange[0], 10, 64)
|
||||
if err != nil {
|
||||
// Parse start; empty start indicates a suffix-byte-range-spec (e.g. bytes=-100)
|
||||
startOffset, err := strconv.ParseInt(bRange[0], 10, strconv.IntSize)
|
||||
if startOffset > int64(math.MaxInt) || startOffset < int64(math.MinInt) {
|
||||
return 0, size, false, errInvalidRange
|
||||
}
|
||||
if err != nil && bRange[0] != "" { // invalid numeric start (non-empty) -> ignore range
|
||||
return 0, size, false, nil
|
||||
}
|
||||
|
||||
// If end part missing (e.g. bytes=100-)
|
||||
if bRange[1] == "" {
|
||||
if bRange[0] == "" { // bytes=- (meaningless) -> ignore
|
||||
return 0, size, false, nil
|
||||
}
|
||||
// start beyond or at size is unsatisfiable -> error (RequestedRangeNotSatisfiable)
|
||||
if startOffset >= size {
|
||||
return 0, 0, false, errInvalidRange
|
||||
}
|
||||
// bytes=100- => from start to end
|
||||
return startOffset, size - startOffset, true, nil
|
||||
}
|
||||
|
||||
endOffset, err := strconv.ParseInt(bRange[1], 10, strconv.IntSize)
|
||||
if endOffset > int64(math.MaxInt) {
|
||||
return 0, size, false, errInvalidRange
|
||||
}
|
||||
if err != nil { // invalid numeric end -> ignore range
|
||||
return 0, size, false, nil
|
||||
}
|
||||
|
||||
// Suffix range handling (bRange[0] == "")
|
||||
if bRange[0] == "" {
|
||||
// Disallow -0 (always unsatisfiable)
|
||||
if endOffset == 0 {
|
||||
return 0, 0, false, errInvalidRange
|
||||
}
|
||||
// For zero-sized objects any positive suffix is treated as invalid (ignored, no error)
|
||||
if size == 0 {
|
||||
return 0, size, false, nil
|
||||
}
|
||||
// Clamp to object size (request more bytes than exist -> entire object)
|
||||
endOffset = min(endOffset, size)
|
||||
return size - endOffset, endOffset, true, nil
|
||||
}
|
||||
|
||||
// Normal range (start-end)
|
||||
if startOffset > endOffset { // start > end -> ignore
|
||||
return 0, size, false, nil
|
||||
}
|
||||
// Start beyond or at end of object -> error
|
||||
if startOffset >= size {
|
||||
return 0, 0, false, errInvalidRange
|
||||
}
|
||||
|
||||
if bRange[1] == "" {
|
||||
return startOffset, size - startOffset, true, nil
|
||||
}
|
||||
|
||||
endOffset, err := strconv.ParseInt(bRange[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, size, false, nil
|
||||
}
|
||||
|
||||
if endOffset < startOffset {
|
||||
return 0, size, false, nil
|
||||
}
|
||||
|
||||
// Adjust end beyond object size (trim)
|
||||
if endOffset >= size {
|
||||
return startOffset, size - startOffset, true, nil
|
||||
endOffset = size - 1
|
||||
}
|
||||
|
||||
return startOffset, endOffset - startOffset + 1, true, nil
|
||||
}
|
||||
|
||||
@@ -205,34 +244,88 @@ func ParseCopySource(copySourceHeader string) (string, string, string, error) {
|
||||
|
||||
srcBucket, srcObject, ok := strings.Cut(copySource, "/")
|
||||
if !ok {
|
||||
return "", "", "", s3err.GetAPIError(s3err.ErrInvalidCopySource)
|
||||
return "", "", "", s3err.GetAPIError(s3err.ErrInvalidCopySourceBucket)
|
||||
}
|
||||
|
||||
return srcBucket, srcObject, versionId, nil
|
||||
}
|
||||
|
||||
// ParseObjectTags parses the url encoded input string into
|
||||
// map[string]string key-value tag set
|
||||
func ParseObjectTags(t string) (map[string]string, error) {
|
||||
if t == "" {
|
||||
// map[string]string with unescaped key/value pair
|
||||
func ParseObjectTags(tagging string) (map[string]string, error) {
|
||||
if tagging == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tagging := make(map[string]string)
|
||||
tagSet := make(map[string]string)
|
||||
|
||||
tagParts := strings.Split(t, "&")
|
||||
for _, prt := range tagParts {
|
||||
p := strings.Split(prt, "=")
|
||||
if len(p) != 2 {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidTag)
|
||||
for tagging != "" {
|
||||
var tag string
|
||||
tag, tagging, _ = strings.Cut(tagging, "&")
|
||||
// if 'tag' before the first appearance of '&' is empty continue
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if len(p[0]) > 128 || len(p[1]) > 256 {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidTag)
|
||||
|
||||
key, value, found := strings.Cut(tag, "=")
|
||||
// if key is empty, but "=" is present, return invalid url ecnoding err
|
||||
if found && key == "" {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidURLEncodedTagging)
|
||||
}
|
||||
tagging[p[0]] = p[1]
|
||||
|
||||
// return invalid tag key, if the key is longer than 128
|
||||
if len(key) > 128 {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidTagKey)
|
||||
}
|
||||
|
||||
// return invalid tag value, if tag value is longer than 256
|
||||
if len(value) > 256 {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidTagValue)
|
||||
}
|
||||
|
||||
// query unescape tag key
|
||||
key, err := url.QueryUnescape(key)
|
||||
if err != nil {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidURLEncodedTagging)
|
||||
}
|
||||
|
||||
// query unescape tag value
|
||||
value, err = url.QueryUnescape(value)
|
||||
if err != nil {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidURLEncodedTagging)
|
||||
}
|
||||
|
||||
// check tag key to be valid
|
||||
if !isValidTagComponent(key) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidTagKey)
|
||||
}
|
||||
|
||||
// check tag value to be valid
|
||||
if !isValidTagComponent(value) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidTagValue)
|
||||
}
|
||||
|
||||
// duplicate keys are not allowed: return invalid url encoding err
|
||||
_, ok := tagSet[key]
|
||||
if ok {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidURLEncodedTagging)
|
||||
}
|
||||
|
||||
tagSet[key] = value
|
||||
}
|
||||
|
||||
return tagging, nil
|
||||
return tagSet, nil
|
||||
}
|
||||
|
||||
var validTagComponent = regexp.MustCompile(`^[a-zA-Z0-9:/_.\-+ ]+$`)
|
||||
|
||||
// isValidTagComponent matches strings which contain letters, decimal digits,
|
||||
// and special chars: '/', '_', '-', '+', '.', ' ' (space)
|
||||
func isValidTagComponent(str string) bool {
|
||||
if str == "" {
|
||||
return true
|
||||
}
|
||||
return validTagComponent.Match([]byte(str))
|
||||
}
|
||||
|
||||
func GetMultipartMD5(parts []types.CompletedPart) string {
|
||||
@@ -269,3 +362,211 @@ func (f *FileSectionReadCloser) Read(p []byte) (int, error) {
|
||||
func (f *FileSectionReadCloser) Close() error {
|
||||
return f.F.Close()
|
||||
}
|
||||
|
||||
// MoveFile moves a file from source to destination.
|
||||
func MoveFile(source, destination string, perm os.FileMode) error {
|
||||
// We use Rename as the atomic operation for object puts. The upload is
|
||||
// written to a temp file to not conflict with any other simultaneous
|
||||
// uploads. The final operation is to move the temp file into place for
|
||||
// the object. This ensures the object semantics of last upload completed
|
||||
// wins and is not some combination of writes from simultaneous uploads.
|
||||
err := os.Rename(source, destination)
|
||||
if err == nil || !errors.Is(err, syscall.EXDEV) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename can fail if the source and destination are not on the same
|
||||
// filesystem. The fallback is to copy the file and then remove the source.
|
||||
// We need to be careful that the desination does not exist before copying
|
||||
// to prevent any other simultaneous writes to the file.
|
||||
sourceFile, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
var destFile *os.File
|
||||
for {
|
||||
destFile, err = os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrExist) {
|
||||
if removeErr := os.Remove(destination); removeErr != nil {
|
||||
return fmt.Errorf("remove existing destination: %w", removeErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("create destination: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy data: %w", err)
|
||||
}
|
||||
|
||||
err = os.Remove(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove source: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateEtag generates a new quoted etag from the provided hash.Hash
|
||||
func GenerateEtag(h hash.Hash) string {
|
||||
dataSum := h.Sum(nil)
|
||||
return fmt.Sprintf("\"%s\"", hex.EncodeToString(dataSum[:]))
|
||||
}
|
||||
|
||||
// AreEtagsSame compares 2 etags by ignoring quotes
|
||||
func AreEtagsSame(e1, e2 string) bool {
|
||||
return strings.Trim(e1, `"`) == strings.Trim(e2, `"`)
|
||||
}
|
||||
|
||||
func getBoolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
type PreConditions struct {
|
||||
IfMatch *string
|
||||
IfNoneMatch *string
|
||||
IfModSince *time.Time
|
||||
IfUnmodeSince *time.Time
|
||||
}
|
||||
|
||||
// EvaluatePreconditions takes the object ETag, the last modified time and
|
||||
// evaluates the read preconditions:
|
||||
// - if-match,
|
||||
// - if-none-match
|
||||
// - if-modified-since
|
||||
// - if-unmodified-since
|
||||
// if-match and if-none-match are ETag comparisions
|
||||
// if-modified-since and if-unmodified-since are last modifed time comparisons
|
||||
func EvaluatePreconditions(etag string, modTime time.Time, preconditions PreConditions) error {
|
||||
if preconditions.IfMatch == nil && preconditions.IfNoneMatch == nil && preconditions.IfModSince == nil && preconditions.IfUnmodeSince == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// convert all conditions to *bool to evaluate the conditions
|
||||
var ifMatch, ifNoneMatch, ifModSince, ifUnmodeSince *bool
|
||||
if preconditions.IfMatch != nil {
|
||||
ifMatch = getBoolPtr(*preconditions.IfMatch == etag)
|
||||
}
|
||||
if preconditions.IfNoneMatch != nil {
|
||||
ifNoneMatch = getBoolPtr(*preconditions.IfNoneMatch != etag)
|
||||
}
|
||||
if preconditions.IfModSince != nil {
|
||||
ifModSince = getBoolPtr(preconditions.IfModSince.UTC().Before(modTime.UTC()))
|
||||
}
|
||||
if preconditions.IfUnmodeSince != nil {
|
||||
ifUnmodeSince = getBoolPtr(preconditions.IfUnmodeSince.UTC().After(modTime.UTC()))
|
||||
}
|
||||
|
||||
if ifMatch != nil {
|
||||
// if `if-match` doesn't matches, return PreconditionFailed
|
||||
if !*ifMatch {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
// if-match matches
|
||||
if *ifMatch {
|
||||
if ifNoneMatch != nil {
|
||||
// if `if-none-match` doesn't match return NotModified
|
||||
if !*ifNoneMatch {
|
||||
return errNotModified
|
||||
}
|
||||
|
||||
// if both `if-match` and `if-none-match` match, return no error
|
||||
return nil
|
||||
}
|
||||
|
||||
// if `if-match` matches but `if-modified-since` is false return NotModified
|
||||
if ifModSince != nil && !*ifModSince {
|
||||
return errNotModified
|
||||
}
|
||||
|
||||
// ignore `if-unmodified-since` as `if-match` is true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if ifNoneMatch != nil {
|
||||
if *ifNoneMatch {
|
||||
// if `if-none-match` is true, but `if-unmodified-since` is false
|
||||
// return PreconditionFailed
|
||||
if ifUnmodeSince != nil && !*ifUnmodeSince {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
// ignore `if-modified-since` as `if-none-match` is true
|
||||
return nil
|
||||
} else {
|
||||
// if `if-none-match` is false and `if-unmodified-since` is false
|
||||
// return PreconditionFailed
|
||||
if ifUnmodeSince != nil && !*ifUnmodeSince {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
// in all other cases when `if-none-match` is false return NotModified
|
||||
return errNotModified
|
||||
}
|
||||
}
|
||||
|
||||
if ifModSince != nil && !*ifModSince {
|
||||
// if both `if-modified-since` and `if-unmodified-since` are false
|
||||
// return PreconditionFailed
|
||||
if ifUnmodeSince != nil && !*ifUnmodeSince {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
// if only `if-modified-since` is false, return NotModified
|
||||
return errNotModified
|
||||
}
|
||||
|
||||
// if `if-unmodified-since` is false return PreconditionFailed
|
||||
if ifUnmodeSince != nil && !*ifUnmodeSince {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EvaluateMatchPreconditions evaluates if-match and if-none-match preconditions
|
||||
func EvaluateMatchPreconditions(etag string, ifMatch, ifNoneMatch *string) error {
|
||||
if ifMatch != nil && *ifMatch != etag {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
if ifNoneMatch != nil && *ifNoneMatch == etag {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ObjectDeletePreconditions struct {
|
||||
IfMatch *string
|
||||
IfMatchLastModTime *time.Time
|
||||
IfMatchSize *int64
|
||||
}
|
||||
|
||||
// EvaluateObjectDeletePreconditions evaluates preconditions for DeleteObject
|
||||
func EvaluateObjectDeletePreconditions(etag string, modTime time.Time, size int64, preconditions ObjectDeletePreconditions) error {
|
||||
ifMatch := preconditions.IfMatch
|
||||
if ifMatch != nil && *ifMatch != etag {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
ifMatchTime := preconditions.IfMatchLastModTime
|
||||
if ifMatchTime != nil && ifMatchTime.Unix() != modTime.Unix() {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
ifMatchSize := preconditions.IfMatchSize
|
||||
if ifMatchSize != nil && *ifMatchSize != size {
|
||||
return errPreconditionFailed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
@@ -52,9 +53,13 @@ var (
|
||||
defaultFilePerm uint32 = 0644
|
||||
)
|
||||
|
||||
func (p *Posix) openTmpFile(dir, bucket, obj string, size int64, acct auth.Account, dofalloc bool) (*tmpfile, error) {
|
||||
func (p *Posix) openTmpFile(dir, bucket, obj string, size int64, acct auth.Account, dofalloc bool, forceNoTmpFile bool) (*tmpfile, error) {
|
||||
uid, gid, doChown := p.getChownIDs(acct)
|
||||
|
||||
if forceNoTmpFile {
|
||||
return p.openMkTemp(dir, bucket, obj, size, dofalloc, uid, gid, doChown)
|
||||
}
|
||||
|
||||
// O_TMPFILE allows for a file handle to an unnamed file in the filesystem.
|
||||
// This can help reduce contention within the namespace (parent directories),
|
||||
// etc. And will auto cleanup the inode on close if we never link this
|
||||
@@ -68,37 +73,7 @@ func (p *Posix) openTmpFile(dir, bucket, obj string, size int64, acct auth.Accou
|
||||
}
|
||||
|
||||
// O_TMPFILE not supported, try fallback
|
||||
err = backend.MkdirAll(dir, uid, gid, doChown, p.newDirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("make temp dir: %w", err)
|
||||
}
|
||||
f, err := os.CreateTemp(dir,
|
||||
fmt.Sprintf("%x.", sha256.Sum256([]byte(obj))))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmp := &tmpfile{
|
||||
f: f,
|
||||
bucket: bucket,
|
||||
objname: obj,
|
||||
size: size,
|
||||
needsChown: doChown,
|
||||
uid: uid,
|
||||
gid: gid,
|
||||
}
|
||||
// falloc is best effort, its fine if this fails
|
||||
if size > 0 && dofalloc {
|
||||
tmp.falloc()
|
||||
}
|
||||
|
||||
if doChown {
|
||||
err := f.Chown(uid, gid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set temp file ownership: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tmp, nil
|
||||
return p.openMkTemp(dir, bucket, obj, size, dofalloc, uid, gid, doChown)
|
||||
}
|
||||
|
||||
// for O_TMPFILE, filename is /proc/self/fd/<fd> to be used
|
||||
@@ -132,6 +107,46 @@ func (p *Posix) openTmpFile(dir, bucket, obj string, size int64, acct auth.Accou
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
func (p *Posix) openMkTemp(dir, bucket, obj string, size int64, dofalloc bool, uid, gid int, doChown bool) (*tmpfile, error) {
|
||||
err := backend.MkdirAll(dir, uid, gid, doChown, p.newDirPerm)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EROFS) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrMethodNotAllowed)
|
||||
}
|
||||
return nil, fmt.Errorf("make temp dir: %w", err)
|
||||
}
|
||||
f, err := os.CreateTemp(dir,
|
||||
fmt.Sprintf("%x.", sha256.Sum256([]byte(obj))))
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EROFS) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrMethodNotAllowed)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tmp := &tmpfile{
|
||||
f: f,
|
||||
bucket: bucket,
|
||||
objname: obj,
|
||||
size: size,
|
||||
needsChown: doChown,
|
||||
uid: uid,
|
||||
gid: gid,
|
||||
}
|
||||
// falloc is best effort, its fine if this fails
|
||||
if size > 0 && dofalloc {
|
||||
tmp.falloc()
|
||||
}
|
||||
|
||||
if doChown {
|
||||
err := f.Chown(uid, gid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set temp file ownership: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) falloc() error {
|
||||
err := syscall.Fallocate(int(tmp.f.Fd()), 0, 0, tmp.size)
|
||||
if err != nil {
|
||||
@@ -151,14 +166,10 @@ func (tmp *tmpfile) link() error {
|
||||
// of last upload completed wins and is not some combination of writes
|
||||
// from simultaneous uploads.
|
||||
objPath := filepath.Join(tmp.bucket, tmp.objname)
|
||||
err := os.Remove(objPath)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove stale path: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(objPath)
|
||||
|
||||
err = backend.MkdirAll(dir, tmp.uid, tmp.gid, tmp.needsChown, tmp.newDirPerm)
|
||||
err := backend.MkdirAll(dir, tmp.uid, tmp.gid, tmp.needsChown, tmp.newDirPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("make parent dir: %w", err)
|
||||
}
|
||||
@@ -180,21 +191,33 @@ func (tmp *tmpfile) link() error {
|
||||
}
|
||||
defer dirf.Close()
|
||||
|
||||
for {
|
||||
err = unix.Linkat(int(procdir.Fd()), filepath.Base(tmp.f.Name()),
|
||||
int(dirf.Fd()), filepath.Base(objPath), unix.AT_SYMLINK_FOLLOW)
|
||||
if errors.Is(err, syscall.EEXIST) {
|
||||
err := os.Remove(objPath)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove stale path: %w", err)
|
||||
err = unix.Linkat(int(procdir.Fd()), filepath.Base(tmp.f.Name()),
|
||||
int(dirf.Fd()), filepath.Base(objPath), unix.AT_SYMLINK_FOLLOW)
|
||||
if errors.Is(err, syscall.EEXIST) {
|
||||
// Linkat cannot overwrite files; we will allocate a temporary file, Linkat to it and then Renameat it
|
||||
// to avoid potential race condition
|
||||
retries := 1
|
||||
for {
|
||||
tmpName := fmt.Sprintf(".%s.sgwtmp.%d", filepath.Base(objPath), time.Now().UnixNano())
|
||||
err := unix.Linkat(int(procdir.Fd()), filepath.Base(tmp.f.Name()),
|
||||
int(dirf.Fd()), tmpName, unix.AT_SYMLINK_FOLLOW)
|
||||
if errors.Is(err, syscall.EEXIST) && retries < 3 {
|
||||
retries += 1
|
||||
continue
|
||||
}
|
||||
continue
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot find free temporary file: %w", err)
|
||||
}
|
||||
|
||||
err = unix.Renameat(int(dirf.Fd()), tmpName, int(dirf.Fd()), filepath.Base(objPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("overwriting renameat failed: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("link tmpfile (fd %q as %q): %w",
|
||||
filepath.Base(tmp.f.Name()), objPath, err)
|
||||
}
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("link tmpfile (fd %q as %q): %w",
|
||||
filepath.Base(tmp.f.Name()), objPath, err)
|
||||
}
|
||||
|
||||
err = tmp.f.Close()
|
||||
@@ -222,7 +245,9 @@ func (tmp *tmpfile) fallbackLink() error {
|
||||
objPath := filepath.Join(tmp.bucket, tmp.objname)
|
||||
err = os.Rename(tempname, objPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename tmpfile: %w", err)
|
||||
// rename only works for files within the same filesystem
|
||||
// if this fails fallback to copy
|
||||
return backend.MoveFile(tempname, objPath, fs.FileMode(defaultFilePerm))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -38,7 +38,7 @@ type tmpfile struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
func (p *Posix) openTmpFile(dir, bucket, obj string, size int64, acct auth.Account, _ bool) (*tmpfile, error) {
|
||||
func (p *Posix) openTmpFile(dir, bucket, obj string, size int64, acct auth.Account, _ bool, _ bool) (*tmpfile, error) {
|
||||
uid, gid, doChown := p.getChownIDs(acct)
|
||||
|
||||
// Create a temp file for upload while in progress (see link comments below).
|
||||
@@ -80,31 +80,17 @@ func (tmp *tmpfile) link() error {
|
||||
// this will no longer exist
|
||||
defer os.Remove(tempname)
|
||||
|
||||
// We use Rename as the atomic operation for object puts. The upload is
|
||||
// written to a temp file to not conflict with any other simultaneous
|
||||
// uploads. The final operation is to move the temp file into place for
|
||||
// the object. This ensures the object semantics of last upload completed
|
||||
// wins and is not some combination of writes from simultaneous uploads.
|
||||
objPath := filepath.Join(tmp.bucket, tmp.objname)
|
||||
err := os.Remove(objPath)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove stale path: %w", err)
|
||||
}
|
||||
|
||||
// reset default file mode because CreateTemp uses 0600
|
||||
tmp.f.Chmod(defaultFilePerm)
|
||||
|
||||
err = tmp.f.Close()
|
||||
err := tmp.f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("close tmpfile: %w", err)
|
||||
}
|
||||
|
||||
err = os.Rename(tempname, objPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename tmpfile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return backend.MoveFile(tempname, objPath, defaultFilePerm)
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) Write(b []byte) (int, error) {
|
||||
|
||||
@@ -36,6 +36,11 @@ func (s *S3Proxy) getClientWithCtx(ctx context.Context) (*s3.Client, error) {
|
||||
if s.endpoint != "" {
|
||||
return s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = &s.endpoint
|
||||
o.UsePathStyle = s.usePathStyle
|
||||
// The http body stream is not seekable, so most operations cannot
|
||||
// be retried. The error returned to the original client may be
|
||||
// retried by the client.
|
||||
o.Retryer = aws.NopRetryer{}
|
||||
}), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,14 @@
|
||||
package s3proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
@@ -40,7 +34,13 @@ import (
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
const aclKey string = "versitygwAcl"
|
||||
type metaPrefix string
|
||||
|
||||
const (
|
||||
metaPrefixAcl metaPrefix = "vgw-meta-acl-"
|
||||
metaPrefixPolicy metaPrefix = "vgw-meta-policy-"
|
||||
metaPrefixCors metaPrefix = "vgw-meta-cors-"
|
||||
)
|
||||
|
||||
type S3Proxy struct {
|
||||
backend.BackendUnsupported
|
||||
@@ -51,29 +51,48 @@ type S3Proxy struct {
|
||||
secret string
|
||||
endpoint string
|
||||
awsRegion string
|
||||
metaBucket string
|
||||
disableChecksum bool
|
||||
sslSkipVerify bool
|
||||
usePathStyle bool
|
||||
debug bool
|
||||
}
|
||||
|
||||
var _ backend.Backend = &S3Proxy{}
|
||||
|
||||
func New(access, secret, endpoint, region string, disableChecksum, sslSkipVerify, debug bool) (*S3Proxy, error) {
|
||||
func NewWithClient(ctx context.Context, client *s3.Client, metaBucket string) (*S3Proxy, error) {
|
||||
s := &S3Proxy{
|
||||
metaBucket: metaBucket,
|
||||
}
|
||||
s.client = client
|
||||
return s, s.validate(ctx)
|
||||
}
|
||||
|
||||
func New(ctx context.Context, access, secret, endpoint, region, metaBucket string, disableChecksum, sslSkipVerify, usePathStyle, debug bool) (*S3Proxy, error) {
|
||||
s := &S3Proxy{
|
||||
access: access,
|
||||
secret: secret,
|
||||
endpoint: endpoint,
|
||||
awsRegion: region,
|
||||
metaBucket: metaBucket,
|
||||
disableChecksum: disableChecksum,
|
||||
sslSkipVerify: sslSkipVerify,
|
||||
usePathStyle: usePathStyle,
|
||||
debug: debug,
|
||||
}
|
||||
client, err := s.getClientWithCtx(context.Background())
|
||||
client, err := s.getClientWithCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.client = client
|
||||
return s, nil
|
||||
return s, s.validate(ctx)
|
||||
}
|
||||
|
||||
func (s *S3Proxy) validate(ctx context.Context) error {
|
||||
if s.metaBucket != "" && !s.bucketExists(ctx, s.metaBucket) {
|
||||
return fmt.Errorf("the provided meta bucket doesn't exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) ListBuckets(ctx context.Context, input s3response.ListBucketsInput) (s3response.ListAllMyBucketsResult, error) {
|
||||
@@ -88,10 +107,33 @@ func (s *S3Proxy) ListBuckets(ctx context.Context, input s3response.ListBucketsI
|
||||
|
||||
var buckets []s3response.ListAllMyBucketsEntry
|
||||
for _, b := range output.Buckets {
|
||||
buckets = append(buckets, s3response.ListAllMyBucketsEntry{
|
||||
Name: *b.Name,
|
||||
CreationDate: *b.CreationDate,
|
||||
})
|
||||
if *b.Name == s.metaBucket {
|
||||
continue
|
||||
}
|
||||
if input.IsAdmin || s.metaBucket == "" {
|
||||
buckets = append(buckets, s3response.ListAllMyBucketsEntry{
|
||||
Name: *b.Name,
|
||||
CreationDate: *b.CreationDate,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := s.getMetaBucketObjData(ctx, *b.Name, metaPrefixAcl, false)
|
||||
if err != nil {
|
||||
return s3response.ListAllMyBucketsResult{}, handleError(err)
|
||||
}
|
||||
|
||||
acl, err := auth.ParseACL(data)
|
||||
if err != nil {
|
||||
return s3response.ListAllMyBucketsResult{}, err
|
||||
}
|
||||
|
||||
if acl.Owner == input.Owner {
|
||||
buckets = append(buckets, s3response.ListAllMyBucketsEntry{
|
||||
Name: *b.Name,
|
||||
CreationDate: *b.CreationDate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return s3response.ListAllMyBucketsResult{
|
||||
@@ -130,27 +172,52 @@ func (s *S3Proxy) CreateBucket(ctx context.Context, input *s3.CreateBucketInput,
|
||||
if input.GrantWriteACP != nil && *input.GrantWriteACP == "" {
|
||||
input.GrantWriteACP = nil
|
||||
}
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3err.GetAPIError(s3err.ErrBucketAlreadyExists)
|
||||
}
|
||||
|
||||
acct, ok := ctx.Value("account").(auth.Account)
|
||||
if !ok {
|
||||
acct = auth.Account{}
|
||||
}
|
||||
|
||||
if s.metaBucket != "" {
|
||||
data, err := s.getMetaBucketObjData(ctx, *input.Bucket, metaPrefixAcl, true)
|
||||
if err == nil {
|
||||
acl, err := auth.ParseACL(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if acl.Owner == acct.Access {
|
||||
return s3err.GetAPIError(s3err.ErrBucketAlreadyOwnedByYou)
|
||||
}
|
||||
return s3err.GetAPIError(s3err.ErrBucketAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.client.CreateBucket(ctx, input)
|
||||
if err != nil {
|
||||
return handleError(err)
|
||||
}
|
||||
|
||||
var tagSet []types.Tag
|
||||
tagSet = append(tagSet, types.Tag{
|
||||
Key: backend.GetPtrFromString(aclKey),
|
||||
Value: backend.GetPtrFromString(base64Encode(acl)),
|
||||
})
|
||||
// Store bucket default acl
|
||||
if s.metaBucket != "" {
|
||||
err = s.putMetaBucketObj(ctx, *input.Bucket, acl, metaPrefixAcl)
|
||||
if err != nil {
|
||||
// attempt to cleanup
|
||||
_ = s.DeleteBucket(ctx, *input.Bucket)
|
||||
return handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.client.PutBucketTagging(ctx, &s3.PutBucketTaggingInput{
|
||||
Bucket: input.Bucket,
|
||||
Tagging: &types.Tagging{
|
||||
TagSet: tagSet,
|
||||
},
|
||||
})
|
||||
return handleError(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) DeleteBucket(ctx context.Context, bucket string) error {
|
||||
if bucket == s.metaBucket {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
_, err := s.client.DeleteBucket(ctx, &s3.DeleteBucketInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
@@ -158,6 +225,9 @@ func (s *S3Proxy) DeleteBucket(ctx context.Context, bucket string) error {
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutBucketOwnershipControls(ctx context.Context, bucket string, ownership types.ObjectOwnership) error {
|
||||
if bucket == s.metaBucket {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
_, err := s.client.PutBucketOwnershipControls(ctx, &s3.PutBucketOwnershipControlsInput{
|
||||
Bucket: &bucket,
|
||||
OwnershipControls: &types.OwnershipControls{
|
||||
@@ -172,6 +242,9 @@ func (s *S3Proxy) PutBucketOwnershipControls(ctx context.Context, bucket string,
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetBucketOwnershipControls(ctx context.Context, bucket string) (types.ObjectOwnership, error) {
|
||||
if bucket == s.metaBucket {
|
||||
return "", s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
var ownship types.ObjectOwnership
|
||||
resp, err := s.client.GetBucketOwnershipControls(ctx, &s3.GetBucketOwnershipControlsInput{
|
||||
Bucket: &bucket,
|
||||
@@ -182,6 +255,9 @@ func (s *S3Proxy) GetBucketOwnershipControls(ctx context.Context, bucket string)
|
||||
return resp.OwnershipControls.Rules[0].ObjectOwnership, nil
|
||||
}
|
||||
func (s *S3Proxy) DeleteBucketOwnershipControls(ctx context.Context, bucket string) error {
|
||||
if bucket == s.metaBucket {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
_, err := s.client.DeleteBucketOwnershipControls(ctx, &s3.DeleteBucketOwnershipControlsInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
@@ -189,6 +265,9 @@ func (s *S3Proxy) DeleteBucketOwnershipControls(ctx context.Context, bucket stri
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutBucketVersioning(ctx context.Context, bucket string, status types.BucketVersioningStatus) error {
|
||||
if bucket == s.metaBucket {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
_, err := s.client.PutBucketVersioning(ctx, &s3.PutBucketVersioningInput{
|
||||
Bucket: &bucket,
|
||||
VersioningConfiguration: &types.VersioningConfiguration{
|
||||
@@ -200,6 +279,9 @@ func (s *S3Proxy) PutBucketVersioning(ctx context.Context, bucket string, status
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetBucketVersioning(ctx context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) {
|
||||
if bucket == s.metaBucket {
|
||||
return s3response.GetBucketVersioningOutput{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
out, err := s.client.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
@@ -211,6 +293,9 @@ func (s *S3Proxy) GetBucketVersioning(ctx context.Context, bucket string) (s3res
|
||||
}
|
||||
|
||||
func (s *S3Proxy) ListObjectVersions(ctx context.Context, input *s3.ListObjectVersionsInput) (s3response.ListVersionsResult, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.ListVersionsResult{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.Delimiter != nil && *input.Delimiter == "" {
|
||||
input.Delimiter = nil
|
||||
}
|
||||
@@ -248,13 +333,16 @@ func (s *S3Proxy) ListObjectVersions(ctx context.Context, input *s3.ListObjectVe
|
||||
NextVersionIdMarker: out.NextVersionIdMarker,
|
||||
Prefix: out.Prefix,
|
||||
VersionIdMarker: input.VersionIdMarker,
|
||||
Versions: out.Versions,
|
||||
Versions: convertObjectVersions(out.Versions),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var defTime = time.Time{}
|
||||
|
||||
func (s *S3Proxy) CreateMultipartUpload(ctx context.Context, input s3response.CreateMultipartUploadInput) (s3response.InitiateMultipartUploadResult, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.InitiateMultipartUploadResult{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.CacheControl != nil && *input.CacheControl == "" {
|
||||
input.CacheControl = nil
|
||||
}
|
||||
@@ -288,7 +376,7 @@ func (s *S3Proxy) CreateMultipartUpload(ctx context.Context, input s3response.Cr
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
if input.ObjectLockRetainUntilDate != nil && *input.ObjectLockRetainUntilDate == defTime {
|
||||
if input.ObjectLockRetainUntilDate != nil && (*input.ObjectLockRetainUntilDate).Equal(defTime) {
|
||||
input.ObjectLockRetainUntilDate = nil
|
||||
}
|
||||
if input.SSECustomerAlgorithm != nil && *input.SSECustomerAlgorithm == "" {
|
||||
@@ -365,7 +453,12 @@ func (s *S3Proxy) CreateMultipartUpload(ctx context.Context, input s3response.Cr
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) CompleteMultipartUpload(ctx context.Context, input *s3.CompleteMultipartUploadInput) (*s3.CompleteMultipartUploadOutput, error) {
|
||||
func (s *S3Proxy) CompleteMultipartUpload(ctx context.Context, input *s3.CompleteMultipartUploadInput) (s3response.CompleteMultipartUploadResult, string, error) {
|
||||
var res s3response.CompleteMultipartUploadResult
|
||||
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return res, "", s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ChecksumCRC32 != nil && *input.ChecksumCRC32 == "" {
|
||||
input.ChecksumCRC32 = nil
|
||||
}
|
||||
@@ -403,15 +496,37 @@ func (s *S3Proxy) CompleteMultipartUpload(ctx context.Context, input *s3.Complet
|
||||
input.SSECustomerKeyMD5 = nil
|
||||
}
|
||||
|
||||
var versionid string
|
||||
out, err := s.client.CompleteMultipartUpload(ctx, input)
|
||||
return out, handleError(err)
|
||||
if out != nil {
|
||||
res = s3response.CompleteMultipartUploadResult{
|
||||
Location: out.Location,
|
||||
Bucket: out.Bucket,
|
||||
Key: out.Key,
|
||||
ETag: out.ETag,
|
||||
ChecksumCRC32: out.ChecksumCRC32,
|
||||
ChecksumCRC32C: out.ChecksumCRC32C,
|
||||
ChecksumCRC64NVME: out.ChecksumCRC64NVME,
|
||||
ChecksumSHA1: out.ChecksumSHA1,
|
||||
ChecksumSHA256: out.ChecksumSHA256,
|
||||
ChecksumType: &out.ChecksumType,
|
||||
}
|
||||
if out.VersionId != nil {
|
||||
versionid = *out.VersionId
|
||||
}
|
||||
}
|
||||
|
||||
return res, versionid, handleError(err)
|
||||
}
|
||||
|
||||
func (s *S3Proxy) AbortMultipartUpload(ctx context.Context, input *s3.AbortMultipartUploadInput) error {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
if input.IfMatchInitiatedTime != nil && *input.IfMatchInitiatedTime == defTime {
|
||||
if input.IfMatchInitiatedTime != nil && (*input.IfMatchInitiatedTime).Equal(defTime) {
|
||||
input.IfMatchInitiatedTime = nil
|
||||
}
|
||||
_, err := s.client.AbortMultipartUpload(ctx, input)
|
||||
@@ -419,6 +534,9 @@ func (s *S3Proxy) AbortMultipartUpload(ctx context.Context, input *s3.AbortMulti
|
||||
}
|
||||
|
||||
func (s *S3Proxy) ListMultipartUploads(ctx context.Context, input *s3.ListMultipartUploadsInput) (s3response.ListMultipartUploadsResult, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.ListMultipartUploadsResult{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.Delimiter != nil && *input.Delimiter == "" {
|
||||
input.Delimiter = nil
|
||||
}
|
||||
@@ -487,6 +605,9 @@ func (s *S3Proxy) ListMultipartUploads(ctx context.Context, input *s3.ListMultip
|
||||
}
|
||||
|
||||
func (s *S3Proxy) ListParts(ctx context.Context, input *s3.ListPartsInput) (s3response.ListPartsResult, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.ListPartsResult{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
@@ -561,6 +682,9 @@ func (s *S3Proxy) ListParts(ctx context.Context, input *s3.ListPartsInput) (s3re
|
||||
}
|
||||
|
||||
func (s *S3Proxy) UploadPart(ctx context.Context, input *s3.UploadPartInput) (*s3.UploadPartOutput, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ChecksumCRC32 != nil && *input.ChecksumCRC32 == "" {
|
||||
input.ChecksumCRC32 = nil
|
||||
}
|
||||
@@ -601,16 +725,19 @@ func (s *S3Proxy) UploadPart(ctx context.Context, input *s3.UploadPartInput) (*s
|
||||
}
|
||||
|
||||
func (s *S3Proxy) UploadPartCopy(ctx context.Context, input *s3.UploadPartCopyInput) (s3response.CopyPartResult, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.CopyPartResult{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.CopySourceIfMatch != nil && *input.CopySourceIfMatch == "" {
|
||||
input.CopySourceIfMatch = nil
|
||||
}
|
||||
if input.CopySourceIfModifiedSince != nil && *input.CopySourceIfModifiedSince == defTime {
|
||||
if input.CopySourceIfModifiedSince != nil && (*input.CopySourceIfModifiedSince).Equal(defTime) {
|
||||
input.CopySourceIfModifiedSince = nil
|
||||
}
|
||||
if input.CopySourceIfNoneMatch != nil && *input.CopySourceIfNoneMatch == "" {
|
||||
input.CopySourceIfNoneMatch = nil
|
||||
}
|
||||
if input.CopySourceIfUnmodifiedSince != nil && *input.CopySourceIfUnmodifiedSince == defTime {
|
||||
if input.CopySourceIfUnmodifiedSince != nil && (*input.CopySourceIfUnmodifiedSince).Equal(defTime) {
|
||||
input.CopySourceIfUnmodifiedSince = nil
|
||||
}
|
||||
if input.CopySourceRange != nil && *input.CopySourceRange == "" {
|
||||
@@ -658,6 +785,9 @@ func (s *S3Proxy) UploadPartCopy(ctx context.Context, input *s3.UploadPartCopyIn
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutObject(ctx context.Context, input s3response.PutObjectInput) (s3response.PutObjectOutput, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.PutObjectOutput{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.CacheControl != nil && *input.CacheControl == "" {
|
||||
input.CacheControl = nil
|
||||
}
|
||||
@@ -808,23 +938,27 @@ func (s *S3Proxy) PutObject(ctx context.Context, input s3response.PutObjectInput
|
||||
ChecksumCRC64NVME: output.ChecksumCRC64NVME,
|
||||
ChecksumSHA1: output.ChecksumSHA1,
|
||||
ChecksumSHA256: output.ChecksumSHA256,
|
||||
Size: output.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
if input.IfMatch != nil && *input.IfMatch == "" {
|
||||
input.IfMatch = nil
|
||||
}
|
||||
if input.IfModifiedSince != nil && *input.IfModifiedSince == defTime {
|
||||
if input.IfModifiedSince != nil && (*input.IfModifiedSince).Equal(defTime) {
|
||||
input.IfModifiedSince = nil
|
||||
}
|
||||
if input.IfNoneMatch != nil && *input.IfNoneMatch == "" {
|
||||
input.IfNoneMatch = nil
|
||||
}
|
||||
if input.IfUnmodifiedSince != nil && *input.IfUnmodifiedSince == defTime {
|
||||
if input.IfUnmodifiedSince != nil && (*input.IfUnmodifiedSince).Equal(defTime) {
|
||||
input.IfUnmodifiedSince = nil
|
||||
}
|
||||
if input.PartNumber != nil && *input.PartNumber == 0 {
|
||||
@@ -848,7 +982,7 @@ func (s *S3Proxy) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s
|
||||
if input.ResponseContentType != nil && *input.ResponseContentType == "" {
|
||||
input.ResponseContentType = nil
|
||||
}
|
||||
if input.ResponseExpires != nil && *input.ResponseExpires == defTime {
|
||||
if input.ResponseExpires != nil && (*input.ResponseExpires).Equal(defTime) {
|
||||
input.ResponseExpires = nil
|
||||
}
|
||||
if input.SSECustomerAlgorithm != nil && *input.SSECustomerAlgorithm == "" {
|
||||
@@ -869,19 +1003,22 @@ func (s *S3Proxy) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
if input.IfMatch != nil && *input.IfMatch == "" {
|
||||
input.IfMatch = nil
|
||||
}
|
||||
if input.IfModifiedSince != nil && *input.IfModifiedSince == defTime {
|
||||
if input.IfModifiedSince != nil && (*input.IfModifiedSince).Equal(defTime) {
|
||||
input.IfModifiedSince = nil
|
||||
}
|
||||
if input.IfNoneMatch != nil && *input.IfNoneMatch == "" {
|
||||
input.IfNoneMatch = nil
|
||||
}
|
||||
if input.IfUnmodifiedSince != nil && *input.IfUnmodifiedSince == defTime {
|
||||
if input.IfUnmodifiedSince != nil && (*input.IfUnmodifiedSince).Equal(defTime) {
|
||||
input.IfUnmodifiedSince = nil
|
||||
}
|
||||
if input.PartNumber != nil && *input.PartNumber == 0 {
|
||||
@@ -905,7 +1042,7 @@ func (s *S3Proxy) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.
|
||||
if input.ResponseContentType != nil && *input.ResponseContentType == "" {
|
||||
input.ResponseContentType = nil
|
||||
}
|
||||
if input.ResponseExpires != nil && *input.ResponseExpires == defTime {
|
||||
if input.ResponseExpires != nil && (*input.ResponseExpires).Equal(defTime) {
|
||||
input.ResponseExpires = nil
|
||||
}
|
||||
if input.SSECustomerAlgorithm != nil && *input.SSECustomerAlgorithm == "" {
|
||||
@@ -930,6 +1067,9 @@ func (s *S3Proxy) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.GetObjectAttributesResponse{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
@@ -988,7 +1128,10 @@ func (s *S3Proxy) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAt
|
||||
}, handleError(err)
|
||||
}
|
||||
|
||||
func (s *S3Proxy) CopyObject(ctx context.Context, input s3response.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||
func (s *S3Proxy) CopyObject(ctx context.Context, input s3response.CopyObjectInput) (s3response.CopyObjectOutput, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.CopyObjectOutput{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.CacheControl != nil && *input.CacheControl == "" {
|
||||
input.CacheControl = nil
|
||||
}
|
||||
@@ -1007,13 +1150,13 @@ func (s *S3Proxy) CopyObject(ctx context.Context, input s3response.CopyObjectInp
|
||||
if input.CopySourceIfMatch != nil && *input.CopySourceIfMatch == "" {
|
||||
input.CopySourceIfMatch = nil
|
||||
}
|
||||
if input.CopySourceIfModifiedSince != nil && *input.CopySourceIfModifiedSince == defTime {
|
||||
if input.CopySourceIfModifiedSince != nil && (*input.CopySourceIfModifiedSince).Equal(defTime) {
|
||||
input.CopySourceIfModifiedSince = nil
|
||||
}
|
||||
if input.CopySourceIfNoneMatch != nil && *input.CopySourceIfNoneMatch == "" {
|
||||
input.CopySourceIfNoneMatch = nil
|
||||
}
|
||||
if input.CopySourceIfUnmodifiedSince != nil && *input.CopySourceIfUnmodifiedSince == defTime {
|
||||
if input.CopySourceIfUnmodifiedSince != nil && (*input.CopySourceIfUnmodifiedSince).Equal(defTime) {
|
||||
input.CopySourceIfUnmodifiedSince = nil
|
||||
}
|
||||
if input.CopySourceSSECustomerAlgorithm != nil && *input.CopySourceSSECustomerAlgorithm == "" {
|
||||
@@ -1046,7 +1189,7 @@ func (s *S3Proxy) CopyObject(ctx context.Context, input s3response.CopyObjectInp
|
||||
if input.GrantWriteACP != nil && *input.GrantWriteACP == "" {
|
||||
input.GrantWriteACP = nil
|
||||
}
|
||||
if input.ObjectLockRetainUntilDate != nil && *input.ObjectLockRetainUntilDate == defTime {
|
||||
if input.ObjectLockRetainUntilDate != nil && (*input.ObjectLockRetainUntilDate).Equal(defTime) {
|
||||
input.ObjectLockRetainUntilDate = nil
|
||||
}
|
||||
if input.SSECustomerAlgorithm != nil && *input.SSECustomerAlgorithm == "" {
|
||||
@@ -1123,10 +1266,39 @@ func (s *S3Proxy) CopyObject(ctx context.Context, input s3response.CopyObjectInp
|
||||
StorageClass: input.StorageClass,
|
||||
TaggingDirective: input.TaggingDirective,
|
||||
})
|
||||
return out, handleError(err)
|
||||
if err != nil {
|
||||
return s3response.CopyObjectOutput{}, handleError(err)
|
||||
}
|
||||
if out.CopyObjectResult == nil {
|
||||
out.CopyObjectResult = &types.CopyObjectResult{}
|
||||
}
|
||||
return s3response.CopyObjectOutput{
|
||||
BucketKeyEnabled: out.BucketKeyEnabled,
|
||||
CopyObjectResult: &s3response.CopyObjectResult{
|
||||
ChecksumCRC32: out.CopyObjectResult.ChecksumCRC32,
|
||||
ChecksumCRC32C: out.CopyObjectResult.ChecksumCRC32C,
|
||||
ChecksumCRC64NVME: out.CopyObjectResult.ChecksumCRC64NVME,
|
||||
ChecksumSHA1: out.CopyObjectResult.ChecksumSHA1,
|
||||
ChecksumSHA256: out.CopyObjectResult.ChecksumSHA256,
|
||||
ChecksumType: out.CopyObjectResult.ChecksumType,
|
||||
ETag: out.CopyObjectResult.ETag,
|
||||
LastModified: out.CopyObjectResult.LastModified,
|
||||
},
|
||||
CopySourceVersionId: out.CopySourceVersionId,
|
||||
Expiration: out.Expiration,
|
||||
SSECustomerAlgorithm: out.SSECustomerAlgorithm,
|
||||
SSECustomerKeyMD5: out.SSECustomerKeyMD5,
|
||||
SSEKMSEncryptionContext: out.SSEKMSEncryptionContext,
|
||||
SSEKMSKeyId: out.SSEKMSKeyId,
|
||||
ServerSideEncryption: out.ServerSideEncryption,
|
||||
VersionId: out.VersionId,
|
||||
}, handleError(err)
|
||||
}
|
||||
|
||||
func (s *S3Proxy) ListObjects(ctx context.Context, input *s3.ListObjectsInput) (s3response.ListObjectsResult, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.ListObjectsResult{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.Delimiter != nil && *input.Delimiter == "" {
|
||||
input.Delimiter = nil
|
||||
}
|
||||
@@ -1164,6 +1336,9 @@ func (s *S3Proxy) ListObjects(ctx context.Context, input *s3.ListObjectsInput) (
|
||||
}
|
||||
|
||||
func (s *S3Proxy) ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input) (s3response.ListObjectsV2Result, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.ListObjectsV2Result{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ContinuationToken != nil && *input.ContinuationToken == "" {
|
||||
input.ContinuationToken = nil
|
||||
}
|
||||
@@ -1205,13 +1380,16 @@ func (s *S3Proxy) ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Inpu
|
||||
}
|
||||
|
||||
func (s *S3Proxy) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
if input.IfMatch != nil && *input.IfMatch == "" {
|
||||
input.IfMatch = nil
|
||||
}
|
||||
if input.IfMatchLastModifiedTime != nil && *input.IfMatchLastModifiedTime == defTime {
|
||||
if input.IfMatchLastModifiedTime != nil && (*input.IfMatchLastModifiedTime).Equal(defTime) {
|
||||
input.IfMatchLastModifiedTime = nil
|
||||
}
|
||||
if input.IfMatchSize != nil && *input.IfMatchSize == 0 {
|
||||
@@ -1229,6 +1407,9 @@ func (s *S3Proxy) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput)
|
||||
}
|
||||
|
||||
func (s *S3Proxy) DeleteObjects(ctx context.Context, input *s3.DeleteObjectsInput) (s3response.DeleteResult, error) {
|
||||
if *input.Bucket == s.metaBucket {
|
||||
return s3response.DeleteResult{}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
@@ -1252,77 +1433,22 @@ func (s *S3Proxy) DeleteObjects(ctx context.Context, input *s3.DeleteObjectsInpu
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetBucketAcl(ctx context.Context, input *s3.GetBucketAclInput) ([]byte, error) {
|
||||
if input.ExpectedBucketOwner != nil && *input.ExpectedBucketOwner == "" {
|
||||
input.ExpectedBucketOwner = nil
|
||||
}
|
||||
|
||||
tagout, err := s.client.GetBucketTagging(ctx, &s3.GetBucketTaggingInput{
|
||||
Bucket: input.Bucket,
|
||||
})
|
||||
data, err := s.getMetaBucketObjData(ctx, *input.Bucket, metaPrefixAcl, false)
|
||||
if err != nil {
|
||||
var ae smithy.APIError
|
||||
if errors.As(err, &ae) {
|
||||
// sdk issue workaround for missing NoSuchTagSet error type
|
||||
// https://github.com/aws/aws-sdk-go-v2/issues/2878
|
||||
if strings.Contains(ae.ErrorCode(), "NoSuchTagSet") {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if strings.Contains(ae.ErrorCode(), "NotImplemented") {
|
||||
return []byte{}, nil
|
||||
}
|
||||
}
|
||||
return nil, handleError(err)
|
||||
}
|
||||
|
||||
for _, tag := range tagout.TagSet {
|
||||
if *tag.Key == aclKey {
|
||||
acl, err := base64Decode(*tag.Value)
|
||||
if err != nil {
|
||||
return nil, handleError(err)
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
}
|
||||
|
||||
return []byte{}, nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutBucketAcl(ctx context.Context, bucket string, data []byte) error {
|
||||
tagout, err := s.client.GetBucketTagging(ctx, &s3.GetBucketTaggingInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
if err != nil {
|
||||
return handleError(err)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for i, tag := range tagout.TagSet {
|
||||
if *tag.Key == aclKey {
|
||||
tagout.TagSet[i] = types.Tag{
|
||||
Key: backend.GetPtrFromString(aclKey),
|
||||
Value: backend.GetPtrFromString(base64Encode(data)),
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
tagout.TagSet = append(tagout.TagSet, types.Tag{
|
||||
Key: backend.GetPtrFromString(aclKey),
|
||||
Value: backend.GetPtrFromString(base64Encode(data)),
|
||||
})
|
||||
}
|
||||
|
||||
_, err = s.client.PutBucketTagging(ctx, &s3.PutBucketTaggingInput{
|
||||
Bucket: &bucket,
|
||||
Tagging: &types.Tagging{
|
||||
TagSet: tagout.TagSet,
|
||||
},
|
||||
})
|
||||
return handleError(err)
|
||||
return handleError(s.putMetaBucketObj(ctx, bucket, data, metaPrefixAcl))
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutObjectTagging(ctx context.Context, bucket, object string, tags map[string]string) error {
|
||||
if bucket == s.metaBucket {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
tagging := &types.Tagging{
|
||||
TagSet: []types.Tag{},
|
||||
}
|
||||
@@ -1342,6 +1468,9 @@ func (s *S3Proxy) PutObjectTagging(ctx context.Context, bucket, object string, t
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetObjectTagging(ctx context.Context, bucket, object string) (map[string]string, error) {
|
||||
if bucket == s.metaBucket {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
output, err := s.client.GetObjectTagging(ctx, &s3.GetObjectTaggingInput{
|
||||
Bucket: &bucket,
|
||||
Key: &object,
|
||||
@@ -1359,6 +1488,9 @@ func (s *S3Proxy) GetObjectTagging(ctx context.Context, bucket, object string) (
|
||||
}
|
||||
|
||||
func (s *S3Proxy) DeleteObjectTagging(ctx context.Context, bucket, object string) error {
|
||||
if bucket == s.metaBucket {
|
||||
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
_, err := s.client.DeleteObjectTagging(ctx, &s3.DeleteObjectTaggingInput{
|
||||
Bucket: &bucket,
|
||||
Key: &object,
|
||||
@@ -1366,35 +1498,56 @@ func (s *S3Proxy) DeleteObjectTagging(ctx context.Context, bucket, object string
|
||||
return handleError(err)
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutBucketPolicy(ctx context.Context, bucket string, policy []byte) error {
|
||||
_, err := s.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
||||
Bucket: &bucket,
|
||||
Policy: backend.GetPtrFromString(string(policy)),
|
||||
})
|
||||
return handleError(err)
|
||||
func (s *S3Proxy) PutBucketCors(ctx context.Context, bucket string, cors []byte) error {
|
||||
return handleError(s.putMetaBucketObj(ctx, bucket, cors, metaPrefixCors))
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetBucketPolicy(ctx context.Context, bucket string) ([]byte, error) {
|
||||
policy, err := s.client.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
func (s *S3Proxy) GetBucketCors(ctx context.Context, bucket string) ([]byte, error) {
|
||||
data, err := s.getMetaBucketObjData(ctx, bucket, metaPrefixCors, false)
|
||||
if err != nil {
|
||||
return nil, handleError(err)
|
||||
}
|
||||
|
||||
result := []byte{}
|
||||
if policy.Policy != nil {
|
||||
result = []byte(*policy.Policy)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) DeleteBucketCors(ctx context.Context, bucket string) error {
|
||||
key := getMetaKey(bucket, metaPrefixCors)
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &s.metaBucket,
|
||||
Key: &key,
|
||||
})
|
||||
if err != nil && !areErrSame(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) {
|
||||
return handleError(err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutBucketPolicy(ctx context.Context, bucket string, policy []byte) error {
|
||||
return handleError(s.putMetaBucketObj(ctx, bucket, policy, metaPrefixPolicy))
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetBucketPolicy(ctx context.Context, bucket string) ([]byte, error) {
|
||||
data, err := s.getMetaBucketObjData(ctx, bucket, metaPrefixPolicy, false)
|
||||
if err != nil {
|
||||
return nil, handleError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) DeleteBucketPolicy(ctx context.Context, bucket string) error {
|
||||
_, err := s.client.DeleteBucketPolicy(ctx, &s3.DeleteBucketPolicyInput{
|
||||
Bucket: &bucket,
|
||||
key := getMetaKey(bucket, metaPrefixPolicy)
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &s.metaBucket,
|
||||
Key: &key,
|
||||
})
|
||||
return handleError(err)
|
||||
if err != nil && !areErrSame(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) {
|
||||
return handleError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutObjectLockConfiguration(ctx context.Context, bucket string, config []byte) error {
|
||||
@@ -1422,86 +1575,146 @@ func (s *S3Proxy) GetObjectLegalHold(ctx context.Context, bucket, object, versio
|
||||
return nil, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
|
||||
func (s *S3Proxy) ChangeBucketOwner(ctx context.Context, bucket string, acl []byte) error {
|
||||
var acll auth.ACL
|
||||
if err := json.Unmarshal(acl, &acll); err != nil {
|
||||
return fmt.Errorf("unmarshal acl: %w", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/change-bucket-owner/?bucket=%v&owner=%v", s.endpoint, bucket, acll.Owner), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send the request: %w", err)
|
||||
}
|
||||
|
||||
signer := v4.NewSigner()
|
||||
|
||||
hashedPayload := sha256.Sum256([]byte{})
|
||||
hexPayload := hex.EncodeToString(hashedPayload[:])
|
||||
|
||||
req.Header.Set("X-Amz-Content-Sha256", hexPayload)
|
||||
|
||||
signErr := signer.SignHTTP(req.Context(), aws.Credentials{AccessKeyID: s.access, SecretAccessKey: s.secret}, req, hexPayload, "s3", s.awsRegion, time.Now())
|
||||
if signErr != nil {
|
||||
return fmt.Errorf("failed to sign the request: %w", err)
|
||||
}
|
||||
|
||||
client := http.Client{}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send the request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode > 300 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return fmt.Errorf("%v", string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
func (s *S3Proxy) ChangeBucketOwner(ctx context.Context, bucket, owner string) error {
|
||||
return auth.UpdateBucketACLOwner(ctx, s, bucket, owner)
|
||||
}
|
||||
|
||||
func (s *S3Proxy) ListBucketsAndOwners(ctx context.Context) ([]s3response.Bucket, error) {
|
||||
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/list-buckets", s.endpoint), nil)
|
||||
if err != nil {
|
||||
return []s3response.Bucket{}, fmt.Errorf("failed to send the request: %w", err)
|
||||
}
|
||||
|
||||
signer := v4.NewSigner()
|
||||
|
||||
hashedPayload := sha256.Sum256([]byte{})
|
||||
hexPayload := hex.EncodeToString(hashedPayload[:])
|
||||
|
||||
req.Header.Set("X-Amz-Content-Sha256", hexPayload)
|
||||
|
||||
signErr := signer.SignHTTP(req.Context(), aws.Credentials{AccessKeyID: s.access, SecretAccessKey: s.secret}, req, hexPayload, "s3", s.awsRegion, time.Now())
|
||||
if signErr != nil {
|
||||
return []s3response.Bucket{}, fmt.Errorf("failed to sign the request: %w", err)
|
||||
}
|
||||
|
||||
client := http.Client{}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return []s3response.Bucket{}, fmt.Errorf("failed to send the request: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return []s3response.Bucket{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var buckets []s3response.Bucket
|
||||
if err := json.Unmarshal(body, &buckets); err != nil {
|
||||
return []s3response.Bucket{}, err
|
||||
|
||||
paginator := s3.NewListBucketsPaginator(s.client, &s3.ListBucketsInput{})
|
||||
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, handleError(err)
|
||||
}
|
||||
for _, bucket := range page.Buckets {
|
||||
if *bucket.Name == s.metaBucket {
|
||||
continue
|
||||
}
|
||||
aclJSON, err := s.getMetaBucketObjData(ctx, *bucket.Name, metaPrefixAcl, false)
|
||||
if err != nil {
|
||||
return nil, handleError(err)
|
||||
}
|
||||
|
||||
acl, err := auth.ParseACL(aclJSON)
|
||||
if err != nil {
|
||||
return buckets, fmt.Errorf("parse acl tag: %w", err)
|
||||
}
|
||||
|
||||
buckets = append(buckets, s3response.Bucket{
|
||||
Name: *bucket.Name,
|
||||
Owner: acl.Owner,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) bucketExists(ctx context.Context, bucket string) bool {
|
||||
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) putMetaBucketObj(ctx context.Context, bucket string, data []byte, prefix metaPrefix) error {
|
||||
// if meta bucket is not provided, return successful response
|
||||
if s.metaBucket == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := getMetaKey(bucket, prefix)
|
||||
// store the provided bucket acl/policy as an object in meta bucket
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &s.metaBucket,
|
||||
Key: &key,
|
||||
Body: bytes.NewReader(data),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// set checkExists to true if using to check for existence of bucket, in
|
||||
// this case it will not return default acl/policy if the metadata does
|
||||
// not exist
|
||||
func (s *S3Proxy) getMetaBucketObjData(ctx context.Context, bucket string, prefix metaPrefix, checkExists bool) ([]byte, error) {
|
||||
// return default bahviour of get bucket policy/acl, if meta bucket is not provided
|
||||
if s.metaBucket == "" {
|
||||
return handleMetaBucketObjectNotFoundErr(prefix)
|
||||
}
|
||||
|
||||
key := getMetaKey(bucket, prefix)
|
||||
// get meta bucket object
|
||||
res, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &s.metaBucket,
|
||||
Key: &key,
|
||||
})
|
||||
if areErrSame(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) {
|
||||
if checkExists {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handleMetaBucketObjectNotFoundErr(prefix)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read meta object data: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// handles the case when an object with the given metprefix
|
||||
// is not found in meta bucket. Aggregates the not found errors
|
||||
// for each meta prefix
|
||||
func handleMetaBucketObjectNotFoundErr(prefix metaPrefix) ([]byte, error) {
|
||||
switch prefix {
|
||||
case metaPrefixAcl:
|
||||
// If bucket acl is not found, return default acl
|
||||
return []byte{}, nil
|
||||
case metaPrefixPolicy:
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
|
||||
case metaPrefixCors:
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
|
||||
}
|
||||
|
||||
return []byte{}, nil
|
||||
}
|
||||
|
||||
// Checks if the provided err is a type of smithy.APIError
|
||||
// and if the error code and message match with the provided apiErr
|
||||
func areErrSame(err error, apiErr s3err.APIError) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var ae smithy.APIError
|
||||
if errors.As(err, &ae) {
|
||||
if ae.ErrorCode() != apiErr.Code {
|
||||
return false
|
||||
}
|
||||
|
||||
// 404 errors are not well serialized by aws-sdk-go-v2
|
||||
if ae.ErrorCode() != "NoSuchKey" && ae.ErrorMessage() != apiErr.Description {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// generates meta object key with bucket name and meta prefix
|
||||
func getMetaKey(bucket string, prefix metaPrefix) string {
|
||||
return string(prefix) + bucket
|
||||
}
|
||||
|
||||
func handleError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -1522,18 +1735,6 @@ func handleError(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func base64Encode(input []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(input)
|
||||
}
|
||||
|
||||
func base64Decode(encoded string) ([]byte, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func convertObjects(objs []types.Object) []s3response.Object {
|
||||
result := make([]s3response.Object, 0, len(objs))
|
||||
|
||||
@@ -1553,3 +1754,24 @@ func convertObjects(objs []types.Object) []s3response.Object {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func convertObjectVersions(versions []types.ObjectVersion) []s3response.ObjectVersion {
|
||||
result := make([]s3response.ObjectVersion, 0, len(versions))
|
||||
for _, v := range versions {
|
||||
result = append(result, s3response.ObjectVersion{
|
||||
ChecksumAlgorithm: v.ChecksumAlgorithm,
|
||||
ChecksumType: v.ChecksumType,
|
||||
ETag: v.ETag,
|
||||
IsLatest: v.IsLatest,
|
||||
Key: v.Key,
|
||||
LastModified: v.LastModified,
|
||||
Owner: v.Owner,
|
||||
RestoreStatus: v.RestoreStatus,
|
||||
Size: v.Size,
|
||||
StorageClass: v.StorageClass,
|
||||
VersionId: v.VersionId,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -30,21 +29,27 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/pkg/xattr"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/backend/meta"
|
||||
"github.com/versity/versitygw/backend/posix"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
// ScoutfsOpts are the options for the ScoutFS backend
|
||||
type ScoutfsOpts struct {
|
||||
ChownUID bool
|
||||
ChownGID bool
|
||||
GlacierMode bool
|
||||
BucketLinks bool
|
||||
NewDirPerm fs.FileMode
|
||||
// ChownUID sets the UID of the object to the UID of the user on PUT
|
||||
ChownUID bool
|
||||
// ChownGID sets the GID of the object to the GID of the user on PUT
|
||||
ChownGID bool
|
||||
// BucketLinks enables symlinks to directories to be treated as buckets
|
||||
BucketLinks bool
|
||||
//VersioningDir sets the version directory to enable object versioning
|
||||
VersioningDir string
|
||||
// NewDirPerm specifies the permission to set on newly created directories
|
||||
NewDirPerm fs.FileMode
|
||||
// GlacierMode enables glacier emulation for offline files
|
||||
GlacierMode bool
|
||||
// DisableNoArchive prevents setting noarchive on temporary files
|
||||
DisableNoArchive bool
|
||||
}
|
||||
|
||||
@@ -53,9 +58,6 @@ type ScoutFS struct {
|
||||
rootfd *os.File
|
||||
rootdir string
|
||||
|
||||
// bucket/object metadata storage facility
|
||||
meta meta.MetadataStorer
|
||||
|
||||
// glaciermode enables the following behavior:
|
||||
// GET object: if file offline, return invalid object state
|
||||
// HEAD object: if file offline, set obj storage class to GLACIER
|
||||
@@ -67,19 +69,6 @@ type ScoutFS struct {
|
||||
// RestoreObject: add batch stage request to file
|
||||
glaciermode bool
|
||||
|
||||
// chownuid/gid enable chowning of files to the account uid/gid
|
||||
// when objects are uploaded
|
||||
chownuid bool
|
||||
chowngid bool
|
||||
|
||||
// euid/egid are the effective uid/gid of the running versitygw process
|
||||
// used to determine if chowning is needed
|
||||
euid int
|
||||
egid int
|
||||
|
||||
// newDirPerm is the permissions to use when creating new directories
|
||||
newDirPerm fs.FileMode
|
||||
|
||||
// disableNoArchive is used to disable setting scoutam noarchive flag
|
||||
// on mutlipart parts. This is enabled by default to prevent archive
|
||||
// copies of temporary multipart parts.
|
||||
@@ -89,24 +78,6 @@ type ScoutFS struct {
|
||||
var _ backend.Backend = &ScoutFS{}
|
||||
|
||||
const (
|
||||
metaTmpDir = ".sgwtmp"
|
||||
metaTmpMultipartDir = metaTmpDir + "/multipart"
|
||||
tagHdr = "X-Amz-Tagging"
|
||||
metaHdr = "X-Amz-Meta"
|
||||
contentTypeHdr = "content-type"
|
||||
contentEncHdr = "content-encoding"
|
||||
contentLangHdr = "content-language"
|
||||
contentDispHdr = "content-disposition"
|
||||
cacheCtrlHdr = "cache-control"
|
||||
expiresHdr = "expires"
|
||||
emptyMD5 = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
etagkey = "etag"
|
||||
checksumsKey = "checksums"
|
||||
objectRetentionKey = "object-retention"
|
||||
objectLegalHoldKey = "object-legal-hold"
|
||||
)
|
||||
|
||||
var (
|
||||
stageComplete = "ongoing-request=\"false\", expiry-date=\"Fri, 2 Dec 2050 00:00:00 GMT\""
|
||||
stageInProgress = "true"
|
||||
stageNotInProgress = "false"
|
||||
@@ -146,25 +117,6 @@ func (*ScoutFS) String() string {
|
||||
return "ScoutFS Gateway"
|
||||
}
|
||||
|
||||
// getChownIDs returns the uid and gid that should be used for chowning
|
||||
// the object to the account uid/gid. It also returns a boolean indicating
|
||||
// if chowning is needed.
|
||||
func (s *ScoutFS) getChownIDs(acct auth.Account) (int, int, bool) {
|
||||
uid := s.euid
|
||||
gid := s.egid
|
||||
var needsChown bool
|
||||
if s.chownuid && acct.UserID != s.euid {
|
||||
uid = acct.UserID
|
||||
needsChown = true
|
||||
}
|
||||
if s.chowngid && acct.GroupID != s.egid {
|
||||
gid = acct.GroupID
|
||||
needsChown = true
|
||||
}
|
||||
|
||||
return uid, gid, needsChown
|
||||
}
|
||||
|
||||
func (s *ScoutFS) UploadPart(ctx context.Context, input *s3.UploadPartInput) (*s3.UploadPartOutput, error) {
|
||||
out, err := s.Posix.UploadPart(ctx, input)
|
||||
if err != nil {
|
||||
@@ -175,7 +127,7 @@ func (s *ScoutFS) UploadPart(ctx context.Context, input *s3.UploadPartInput) (*s
|
||||
sum := sha256.Sum256([]byte(*input.Key))
|
||||
partPath := filepath.Join(
|
||||
*input.Bucket, // bucket
|
||||
metaTmpMultipartDir, // temp multipart dir
|
||||
posix.MetaTmpMultipartDir, // temp multipart dir
|
||||
fmt.Sprintf("%x", sum), // hashed objname
|
||||
*input.UploadId, // upload id
|
||||
fmt.Sprintf("%v", *input.PartNumber), // part number
|
||||
@@ -193,445 +145,8 @@ func (s *ScoutFS) UploadPart(ctx context.Context, input *s3.UploadPartInput) (*s
|
||||
// CompleteMultipartUpload scoutfs complete upload uses scoutfs move blocks
|
||||
// ioctl to not have to read and copy the part data to the final object. This
|
||||
// saves a read and write cycle for all mutlipart uploads.
|
||||
func (s *ScoutFS) CompleteMultipartUpload(ctx context.Context, input *s3.CompleteMultipartUploadInput) (*s3.CompleteMultipartUploadOutput, error) {
|
||||
acct, ok := ctx.Value("account").(auth.Account)
|
||||
if !ok {
|
||||
acct = auth.Account{}
|
||||
}
|
||||
|
||||
if input.Bucket == nil {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidBucketName)
|
||||
}
|
||||
if input.Key == nil {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
|
||||
}
|
||||
if input.UploadId == nil {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchUpload)
|
||||
}
|
||||
if input.MultipartUpload == nil {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
bucket := *input.Bucket
|
||||
object := *input.Key
|
||||
uploadID := *input.UploadId
|
||||
parts := input.MultipartUpload.Parts
|
||||
|
||||
_, err := os.Stat(bucket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucket)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat bucket: %w", err)
|
||||
}
|
||||
|
||||
sum, err := s.checkUploadIDExists(bucket, object, uploadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objdir := filepath.Join(metaTmpMultipartDir, fmt.Sprintf("%x", sum))
|
||||
|
||||
checksums, err := s.retrieveChecksums(nil, bucket, filepath.Join(objdir, uploadID))
|
||||
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return nil, fmt.Errorf("get mp checksums: %w", err)
|
||||
}
|
||||
|
||||
// ChecksumType should be the same as specified on CreateMultipartUpload
|
||||
if input.ChecksumType != "" && checksums.Type != input.ChecksumType {
|
||||
checksumType := checksums.Type
|
||||
if checksumType == "" {
|
||||
checksumType = types.ChecksumType("null")
|
||||
}
|
||||
|
||||
return nil, s3err.GetChecksumTypeMismatchOnMpErr(checksumType)
|
||||
}
|
||||
|
||||
// check all parts ok
|
||||
last := len(parts) - 1
|
||||
var totalsize int64
|
||||
|
||||
// The initialie values is the lower limit of partNumber: 0
|
||||
var partNumber int32
|
||||
for i, part := range parts {
|
||||
if part.PartNumber == nil {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidPart)
|
||||
}
|
||||
if *part.PartNumber < 1 {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidCompleteMpPartNumber)
|
||||
}
|
||||
if *part.PartNumber <= partNumber {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidPartOrder)
|
||||
}
|
||||
|
||||
partNumber = *part.PartNumber
|
||||
|
||||
partObjPath := filepath.Join(objdir, uploadID, fmt.Sprintf("%v", *part.PartNumber))
|
||||
fullPartPath := filepath.Join(bucket, partObjPath)
|
||||
fi, err := os.Lstat(fullPartPath)
|
||||
if err != nil {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidPart)
|
||||
}
|
||||
|
||||
totalsize += fi.Size()
|
||||
// all parts except the last need to be greater, thena
|
||||
// the minimum allowed size (5 Mib)
|
||||
if i < last && fi.Size() < backend.MinPartSize {
|
||||
return nil, s3err.GetAPIError(s3err.ErrEntityTooSmall)
|
||||
}
|
||||
|
||||
b, err := s.meta.RetrieveAttribute(nil, bucket, partObjPath, etagkey)
|
||||
etag := string(b)
|
||||
if err != nil {
|
||||
etag = ""
|
||||
}
|
||||
if parts[i].ETag == nil || etag != *parts[i].ETag {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidPart)
|
||||
}
|
||||
|
||||
partChecksum, err := s.retrieveChecksums(nil, bucket, partObjPath)
|
||||
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return nil, fmt.Errorf("get part checksum: %w", err)
|
||||
}
|
||||
|
||||
// If checksum has been provided on mp initalization
|
||||
err = validatePartChecksum(partChecksum, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if input.MpuObjectSize != nil && totalsize != *input.MpuObjectSize {
|
||||
return nil, s3err.GetIncorrectMpObjectSizeErr(totalsize, *input.MpuObjectSize)
|
||||
}
|
||||
|
||||
// use totalsize=0 because we wont be writing to the file, only moving
|
||||
// extents around. so we dont want to fallocate this.
|
||||
f, err := s.openTmpFile(filepath.Join(bucket, metaTmpDir), bucket, object, 0, acct)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EDQUOT) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrQuotaExceeded)
|
||||
}
|
||||
return nil, fmt.Errorf("open temp file: %w", err)
|
||||
}
|
||||
defer f.cleanup()
|
||||
|
||||
for _, part := range parts {
|
||||
if part.PartNumber == nil || *part.PartNumber < 1 {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidPart)
|
||||
}
|
||||
|
||||
partObjPath := filepath.Join(objdir, uploadID, fmt.Sprintf("%v", *part.PartNumber))
|
||||
fullPartPath := filepath.Join(bucket, partObjPath)
|
||||
pf, err := os.Open(fullPartPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open part %v: %v", *part.PartNumber, err)
|
||||
}
|
||||
|
||||
// scoutfs move data is a metadata only operation that moves the data
|
||||
// extent references from the source, appeding to the destination.
|
||||
// this needs to be 4k aligned.
|
||||
err = moveData(pf, f.File())
|
||||
pf.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("move blocks part %v: %v", *part.PartNumber, err)
|
||||
}
|
||||
}
|
||||
|
||||
userMetaData := make(map[string]string)
|
||||
upiddir := filepath.Join(objdir, uploadID)
|
||||
objMeta := s.loadUserMetaData(bucket, upiddir, userMetaData)
|
||||
err = s.storeObjectMetadata(f.File(), bucket, object, objMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objname := filepath.Join(bucket, object)
|
||||
dir := filepath.Dir(objname)
|
||||
if dir != "" {
|
||||
uid, gid, doChown := s.getChownIDs(acct)
|
||||
err = backend.MkdirAll(dir, uid, gid, doChown, s.newDirPerm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range userMetaData {
|
||||
err = s.meta.StoreAttribute(f.File(), bucket, object, fmt.Sprintf("%v.%v", metaHdr, k), []byte(v))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set user attr %q: %w", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
// load and set tagging
|
||||
tagging, err := s.meta.RetrieveAttribute(nil, bucket, upiddir, tagHdr)
|
||||
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return nil, fmt.Errorf("get object tagging: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
err := s.meta.StoreAttribute(f.File(), bucket, object, tagHdr, tagging)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set object tagging: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// load and set legal hold
|
||||
lHold, err := s.meta.RetrieveAttribute(nil, bucket, upiddir, objectLegalHoldKey)
|
||||
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return nil, fmt.Errorf("get object legal hold: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
err := s.meta.StoreAttribute(f.File(), bucket, object, objectLegalHoldKey, lHold)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set object legal hold: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// load and set retention
|
||||
ret, err := s.meta.RetrieveAttribute(nil, bucket, upiddir, objectRetentionKey)
|
||||
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return nil, fmt.Errorf("get object retention: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
err := s.meta.StoreAttribute(f.File(), bucket, object, objectRetentionKey, ret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set object retention: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate s3 compatible md5sum for complete multipart.
|
||||
s3MD5 := backend.GetMultipartMD5(parts)
|
||||
|
||||
err = s.meta.StoreAttribute(f.File(), bucket, object, etagkey, []byte(s3MD5))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set etag attr: %w", err)
|
||||
}
|
||||
|
||||
err = f.link()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("link object in namespace: %w", err)
|
||||
}
|
||||
|
||||
// cleanup tmp dirs
|
||||
os.RemoveAll(filepath.Join(bucket, upiddir))
|
||||
// use Remove for objdir in case there are still other uploads
|
||||
// for same object name outstanding
|
||||
os.Remove(filepath.Join(bucket, objdir))
|
||||
|
||||
return &s3.CompleteMultipartUploadOutput{
|
||||
Bucket: &bucket,
|
||||
ETag: &s3MD5,
|
||||
Key: &object,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ScoutFS) storeObjectMetadata(f *os.File, bucket, object string, m objectMetadata) error {
|
||||
if getString(m.ContentType) != "" {
|
||||
err := s.meta.StoreAttribute(f, bucket, object, contentTypeHdr, []byte(*m.ContentType))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set content-type: %w", err)
|
||||
}
|
||||
}
|
||||
if getString(m.ContentEncoding) != "" {
|
||||
err := s.meta.StoreAttribute(f, bucket, object, contentEncHdr, []byte(*m.ContentEncoding))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set content-encoding: %w", err)
|
||||
}
|
||||
}
|
||||
if getString(m.ContentDisposition) != "" {
|
||||
err := s.meta.StoreAttribute(f, bucket, object, contentDispHdr, []byte(*m.ContentDisposition))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set content-disposition: %w", err)
|
||||
}
|
||||
}
|
||||
if getString(m.ContentLanguage) != "" {
|
||||
err := s.meta.StoreAttribute(f, bucket, object, contentLangHdr, []byte(*m.ContentLanguage))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set content-language: %w", err)
|
||||
}
|
||||
}
|
||||
if getString(m.CacheControl) != "" {
|
||||
err := s.meta.StoreAttribute(f, bucket, object, cacheCtrlHdr, []byte(*m.CacheControl))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set cache-control: %w", err)
|
||||
}
|
||||
}
|
||||
if getString(m.Expires) != "" {
|
||||
err := s.meta.StoreAttribute(f, bucket, object, expiresHdr, []byte(*m.Expires))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set cache-control: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePartChecksum(checksum s3response.Checksum, part types.CompletedPart) error {
|
||||
n := numberOfChecksums(part)
|
||||
if n > 1 {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidChecksumPart)
|
||||
}
|
||||
if checksum.Algorithm == "" {
|
||||
if n != 0 {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidPart)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
algo := checksum.Algorithm
|
||||
if n == 0 {
|
||||
return s3err.APIError{
|
||||
Code: "InvalidRequest",
|
||||
Description: fmt.Sprintf("The upload was created using a %v checksum. The complete request must include the checksum for each part. It was missing for part %v in the request.", strings.ToLower(string(algo)), *part.PartNumber),
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
for _, cs := range []struct {
|
||||
checksum *string
|
||||
expectedChecksum string
|
||||
algo types.ChecksumAlgorithm
|
||||
}{
|
||||
{part.ChecksumCRC32, getString(checksum.CRC32), types.ChecksumAlgorithmCrc32},
|
||||
{part.ChecksumCRC32C, getString(checksum.CRC32C), types.ChecksumAlgorithmCrc32c},
|
||||
{part.ChecksumSHA1, getString(checksum.SHA1), types.ChecksumAlgorithmSha1},
|
||||
{part.ChecksumSHA256, getString(checksum.SHA256), types.ChecksumAlgorithmSha256},
|
||||
{part.ChecksumCRC64NVME, getString(checksum.CRC64NVME), types.ChecksumAlgorithmCrc64nvme},
|
||||
} {
|
||||
if cs.checksum == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !utils.IsValidChecksum(*cs.checksum, cs.algo) {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidChecksumPart)
|
||||
}
|
||||
|
||||
if *cs.checksum != cs.expectedChecksum {
|
||||
if algo == cs.algo {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidPart)
|
||||
}
|
||||
|
||||
return s3err.APIError{
|
||||
Code: "BadDigest",
|
||||
Description: fmt.Sprintf("The %v you specified for part %v did not match what we received.", strings.ToLower(string(cs.algo)), *part.PartNumber),
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func numberOfChecksums(part types.CompletedPart) int {
|
||||
counter := 0
|
||||
if getString(part.ChecksumCRC32) != "" {
|
||||
counter++
|
||||
}
|
||||
if getString(part.ChecksumCRC32C) != "" {
|
||||
counter++
|
||||
}
|
||||
if getString(part.ChecksumSHA1) != "" {
|
||||
counter++
|
||||
}
|
||||
if getString(part.ChecksumSHA256) != "" {
|
||||
counter++
|
||||
}
|
||||
if getString(part.ChecksumCRC64NVME) != "" {
|
||||
counter++
|
||||
}
|
||||
|
||||
return counter
|
||||
}
|
||||
|
||||
func (s *ScoutFS) checkUploadIDExists(bucket, object, uploadID string) ([32]byte, error) {
|
||||
sum := sha256.Sum256([]byte(object))
|
||||
objdir := filepath.Join(bucket, metaTmpMultipartDir, fmt.Sprintf("%x", sum))
|
||||
|
||||
_, err := os.Stat(filepath.Join(objdir, uploadID))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return [32]byte{}, s3err.GetAPIError(s3err.ErrNoSuchUpload)
|
||||
}
|
||||
if err != nil {
|
||||
return [32]byte{}, fmt.Errorf("stat upload: %w", err)
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
type objectMetadata struct {
|
||||
ContentType *string
|
||||
ContentEncoding *string
|
||||
ContentDisposition *string
|
||||
ContentLanguage *string
|
||||
CacheControl *string
|
||||
Expires *string
|
||||
}
|
||||
|
||||
// fll out the user metadata map with the metadata for the object
|
||||
// and return the content type and encoding
|
||||
func (s *ScoutFS) loadUserMetaData(bucket, object string, m map[string]string) objectMetadata {
|
||||
ents, err := s.meta.ListAttributes(bucket, object)
|
||||
if err != nil || len(ents) == 0 {
|
||||
return objectMetadata{}
|
||||
}
|
||||
for _, e := range ents {
|
||||
if !isValidMeta(e) {
|
||||
continue
|
||||
}
|
||||
b, err := s.meta.RetrieveAttribute(nil, bucket, object, e)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if b == nil {
|
||||
m[strings.TrimPrefix(e, fmt.Sprintf("%v.", metaHdr))] = ""
|
||||
continue
|
||||
}
|
||||
m[strings.TrimPrefix(e, fmt.Sprintf("%v.", metaHdr))] = string(b)
|
||||
}
|
||||
|
||||
var result objectMetadata
|
||||
|
||||
b, err := s.meta.RetrieveAttribute(nil, bucket, object, contentTypeHdr)
|
||||
if err == nil {
|
||||
result.ContentType = backend.GetPtrFromString(string(b))
|
||||
}
|
||||
|
||||
b, err = s.meta.RetrieveAttribute(nil, bucket, object, contentEncHdr)
|
||||
if err == nil {
|
||||
result.ContentEncoding = backend.GetPtrFromString(string(b))
|
||||
}
|
||||
|
||||
b, err = s.meta.RetrieveAttribute(nil, bucket, object, contentDispHdr)
|
||||
if err == nil {
|
||||
result.ContentDisposition = backend.GetPtrFromString(string(b))
|
||||
}
|
||||
|
||||
b, err = s.meta.RetrieveAttribute(nil, bucket, object, contentLangHdr)
|
||||
if err == nil {
|
||||
result.ContentLanguage = backend.GetPtrFromString(string(b))
|
||||
}
|
||||
|
||||
b, err = s.meta.RetrieveAttribute(nil, bucket, object, cacheCtrlHdr)
|
||||
if err == nil {
|
||||
result.CacheControl = backend.GetPtrFromString(string(b))
|
||||
}
|
||||
|
||||
b, err = s.meta.RetrieveAttribute(nil, bucket, object, expiresHdr)
|
||||
if err == nil {
|
||||
result.Expires = backend.GetPtrFromString(string(b))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func isValidMeta(val string) bool {
|
||||
if strings.HasPrefix(val, metaHdr) {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(val, "Expires") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
func (s *ScoutFS) CompleteMultipartUpload(ctx context.Context, input *s3.CompleteMultipartUploadInput) (s3response.CompleteMultipartUploadResult, string, error) {
|
||||
return s.Posix.CompleteMultipartUploadWithCopy(ctx, input, moveData)
|
||||
}
|
||||
|
||||
func (s *ScoutFS) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
|
||||
@@ -728,208 +243,47 @@ func (s *ScoutFS) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.
|
||||
}
|
||||
|
||||
func (s *ScoutFS) ListObjects(ctx context.Context, input *s3.ListObjectsInput) (s3response.ListObjectsResult, error) {
|
||||
if input.Bucket == nil {
|
||||
return s3response.ListObjectsResult{}, s3err.GetAPIError(s3err.ErrInvalidBucketName)
|
||||
if s.glaciermode {
|
||||
return s.Posix.ListObjectsParametrized(ctx, input, s.glacierFileToObj)
|
||||
} else {
|
||||
return s.Posix.ListObjects(ctx, input)
|
||||
}
|
||||
bucket := *input.Bucket
|
||||
prefix := ""
|
||||
if input.Prefix != nil {
|
||||
prefix = *input.Prefix
|
||||
}
|
||||
marker := ""
|
||||
if input.Marker != nil {
|
||||
marker = *input.Marker
|
||||
}
|
||||
delim := ""
|
||||
if input.Delimiter != nil {
|
||||
delim = *input.Delimiter
|
||||
}
|
||||
maxkeys := int32(0)
|
||||
if input.MaxKeys != nil {
|
||||
maxkeys = *input.MaxKeys
|
||||
}
|
||||
|
||||
_, err := os.Stat(bucket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s3response.ListObjectsResult{}, s3err.GetAPIError(s3err.ErrNoSuchBucket)
|
||||
}
|
||||
if err != nil {
|
||||
return s3response.ListObjectsResult{}, fmt.Errorf("stat bucket: %w", err)
|
||||
}
|
||||
|
||||
fileSystem := os.DirFS(bucket)
|
||||
results, err := backend.Walk(ctx, fileSystem, prefix, delim, marker, maxkeys,
|
||||
s.fileToObj(bucket), []string{metaTmpDir})
|
||||
if err != nil {
|
||||
return s3response.ListObjectsResult{}, fmt.Errorf("walk %v: %w", bucket, err)
|
||||
}
|
||||
|
||||
return s3response.ListObjectsResult{
|
||||
CommonPrefixes: results.CommonPrefixes,
|
||||
Contents: results.Objects,
|
||||
Delimiter: &delim,
|
||||
IsTruncated: &results.Truncated,
|
||||
Marker: &marker,
|
||||
MaxKeys: &maxkeys,
|
||||
Name: &bucket,
|
||||
NextMarker: &results.NextMarker,
|
||||
Prefix: &prefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ScoutFS) ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input) (s3response.ListObjectsV2Result, error) {
|
||||
if input.Bucket == nil {
|
||||
return s3response.ListObjectsV2Result{}, s3err.GetAPIError(s3err.ErrInvalidBucketName)
|
||||
if s.glaciermode {
|
||||
return s.Posix.ListObjectsV2Parametrized(ctx, input, s.glacierFileToObj)
|
||||
} else {
|
||||
return s.Posix.ListObjectsV2(ctx, input)
|
||||
}
|
||||
bucket := *input.Bucket
|
||||
prefix := ""
|
||||
if input.Prefix != nil {
|
||||
prefix = *input.Prefix
|
||||
}
|
||||
marker := ""
|
||||
if input.ContinuationToken != nil {
|
||||
marker = *input.ContinuationToken
|
||||
}
|
||||
delim := ""
|
||||
if input.Delimiter != nil {
|
||||
delim = *input.Delimiter
|
||||
}
|
||||
maxkeys := int32(0)
|
||||
if input.MaxKeys != nil {
|
||||
maxkeys = *input.MaxKeys
|
||||
}
|
||||
|
||||
_, err := os.Stat(bucket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s3response.ListObjectsV2Result{}, s3err.GetAPIError(s3err.ErrNoSuchBucket)
|
||||
}
|
||||
if err != nil {
|
||||
return s3response.ListObjectsV2Result{}, fmt.Errorf("stat bucket: %w", err)
|
||||
}
|
||||
|
||||
fileSystem := os.DirFS(bucket)
|
||||
results, err := backend.Walk(ctx, fileSystem, prefix, delim, marker, int32(maxkeys),
|
||||
s.fileToObj(bucket), []string{metaTmpDir})
|
||||
if err != nil {
|
||||
return s3response.ListObjectsV2Result{}, fmt.Errorf("walk %v: %w", bucket, err)
|
||||
}
|
||||
|
||||
return s3response.ListObjectsV2Result{
|
||||
CommonPrefixes: results.CommonPrefixes,
|
||||
Contents: results.Objects,
|
||||
Delimiter: &delim,
|
||||
IsTruncated: &results.Truncated,
|
||||
ContinuationToken: &marker,
|
||||
MaxKeys: &maxkeys,
|
||||
Name: &bucket,
|
||||
NextContinuationToken: &results.NextMarker,
|
||||
Prefix: &prefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ScoutFS) fileToObj(bucket string) backend.GetObjFunc {
|
||||
// FileToObj function for ListObject calls that adds a Glacier storage class if the file is offline
|
||||
func (s *ScoutFS) glacierFileToObj(bucket string, fetchOwner bool) backend.GetObjFunc {
|
||||
posixFileToObj := s.Posix.FileToObj(bucket, fetchOwner)
|
||||
|
||||
return func(path string, d fs.DirEntry) (s3response.Object, error) {
|
||||
res, err := posixFileToObj(path, d)
|
||||
if err != nil || d.IsDir() {
|
||||
return res, err
|
||||
}
|
||||
objPath := filepath.Join(bucket, path)
|
||||
if d.IsDir() {
|
||||
// directory object only happens if directory empty
|
||||
// check to see if this is a directory object by checking etag
|
||||
etagBytes, err := s.meta.RetrieveAttribute(nil, bucket, path, etagkey)
|
||||
if errors.Is(err, meta.ErrNoSuchKey) || errors.Is(err, fs.ErrNotExist) {
|
||||
return s3response.Object{}, backend.ErrSkipObj
|
||||
}
|
||||
if err != nil {
|
||||
return s3response.Object{}, fmt.Errorf("get etag: %w", err)
|
||||
}
|
||||
etag := string(etagBytes)
|
||||
|
||||
fi, err := d.Info()
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s3response.Object{}, backend.ErrSkipObj
|
||||
}
|
||||
if err != nil {
|
||||
return s3response.Object{}, fmt.Errorf("get fileinfo: %w", err)
|
||||
}
|
||||
|
||||
size := int64(0)
|
||||
mtime := fi.ModTime()
|
||||
|
||||
return s3response.Object{
|
||||
ETag: &etag,
|
||||
Key: &path,
|
||||
LastModified: &mtime,
|
||||
Size: &size,
|
||||
StorageClass: types.ObjectStorageClassStandard,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Retreive the object checksum algorithm
|
||||
checksums, err := s.retrieveChecksums(nil, bucket, path)
|
||||
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return s3response.Object{}, backend.ErrSkipObj
|
||||
}
|
||||
|
||||
// file object, get object info and fill out object data
|
||||
b, err := s.meta.RetrieveAttribute(nil, bucket, path, etagkey)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s3response.Object{}, backend.ErrSkipObj
|
||||
}
|
||||
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return s3response.Object{}, fmt.Errorf("get etag: %w", err)
|
||||
}
|
||||
// note: meta.ErrNoSuchKey will return etagBytes = []byte{}
|
||||
// so this will just set etag to "" if its not already set
|
||||
|
||||
etag := string(b)
|
||||
|
||||
fi, err := d.Info()
|
||||
// Check if there are any offline exents associated with this file.
|
||||
// If so, we will return the Glacier storage class
|
||||
st, err := statMore(objPath)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s3response.Object{}, backend.ErrSkipObj
|
||||
}
|
||||
if err != nil {
|
||||
return s3response.Object{}, fmt.Errorf("get fileinfo: %w", err)
|
||||
return s3response.Object{}, fmt.Errorf("stat more: %w", err)
|
||||
}
|
||||
|
||||
sc := types.ObjectStorageClassStandard
|
||||
if s.glaciermode {
|
||||
// Check if there are any offline exents associated with this file.
|
||||
// If so, we will return the InvalidObjectState error.
|
||||
st, err := statMore(objPath)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s3response.Object{}, backend.ErrSkipObj
|
||||
}
|
||||
if err != nil {
|
||||
return s3response.Object{}, fmt.Errorf("stat more: %w", err)
|
||||
}
|
||||
if st.Offline_blocks != 0 {
|
||||
sc = types.ObjectStorageClassGlacier
|
||||
}
|
||||
if st.Offline_blocks != 0 {
|
||||
res.StorageClass = types.ObjectStorageClassGlacier
|
||||
}
|
||||
|
||||
size := fi.Size()
|
||||
mtime := fi.ModTime()
|
||||
|
||||
return s3response.Object{
|
||||
ETag: &etag,
|
||||
Key: &path,
|
||||
LastModified: &mtime,
|
||||
Size: &size,
|
||||
StorageClass: sc,
|
||||
ChecksumAlgorithm: []types.ChecksumAlgorithm{checksums.Algorithm},
|
||||
ChecksumType: checksums.Type,
|
||||
}, nil
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScoutFS) retrieveChecksums(f *os.File, bucket, object string) (checksums s3response.Checksum, err error) {
|
||||
checksumsAtr, err := s.meta.RetrieveAttribute(f, bucket, object, checksumsKey)
|
||||
if err != nil {
|
||||
return checksums, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(checksumsAtr, &checksums)
|
||||
return checksums, err
|
||||
}
|
||||
|
||||
// RestoreObject will set stage request on file if offline and do nothing if
|
||||
// file is online
|
||||
func (s *ScoutFS) RestoreObject(_ context.Context, input *s3.RestoreObjectInput) error {
|
||||
@@ -955,13 +309,6 @@ func (s *ScoutFS) RestoreObject(_ context.Context, input *s3.RestoreObjectInput)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getString(str *string) string {
|
||||
if str == nil {
|
||||
return ""
|
||||
}
|
||||
return *str
|
||||
}
|
||||
|
||||
func isStaging(objname string) (bool, error) {
|
||||
b, err := xattr.Get(objname, flagskey)
|
||||
if err != nil && !isNoAttr(err) {
|
||||
|
||||
@@ -17,30 +17,24 @@
|
||||
package scoutfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/versity/scoutfs-go"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/backend/meta"
|
||||
"github.com/versity/versitygw/backend/posix"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
)
|
||||
|
||||
func New(rootdir string, opts ScoutfsOpts) (*ScoutFS, error) {
|
||||
metastore := meta.XattrMeta{}
|
||||
|
||||
p, err := posix.New(rootdir, metastore, posix.PosixOpts{
|
||||
ChownUID: opts.ChownUID,
|
||||
ChownGID: opts.ChownGID,
|
||||
BucketLinks: opts.BucketLinks,
|
||||
NewDirPerm: opts.NewDirPerm,
|
||||
ChownUID: opts.ChownUID,
|
||||
ChownGID: opts.ChownGID,
|
||||
BucketLinks: opts.BucketLinks,
|
||||
NewDirPerm: opts.NewDirPerm,
|
||||
VersioningDir: opts.VersioningDir,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -55,135 +49,30 @@ func New(rootdir string, opts ScoutfsOpts) (*ScoutFS, error) {
|
||||
Posix: p,
|
||||
rootfd: f,
|
||||
rootdir: rootdir,
|
||||
meta: metastore,
|
||||
chownuid: opts.ChownUID,
|
||||
chowngid: opts.ChownGID,
|
||||
glaciermode: opts.GlacierMode,
|
||||
newDirPerm: opts.NewDirPerm,
|
||||
disableNoArchive: opts.DisableNoArchive,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const procfddir = "/proc/self/fd"
|
||||
|
||||
type tmpfile struct {
|
||||
f *os.File
|
||||
bucket string
|
||||
objname string
|
||||
size int64
|
||||
needsChown bool
|
||||
uid int
|
||||
gid int
|
||||
newDirPerm fs.FileMode
|
||||
}
|
||||
|
||||
var (
|
||||
defaultFilePerm uint32 = 0644
|
||||
)
|
||||
|
||||
func (s *ScoutFS) openTmpFile(dir, bucket, obj string, size int64, acct auth.Account) (*tmpfile, error) {
|
||||
uid, gid, doChown := s.getChownIDs(acct)
|
||||
|
||||
// O_TMPFILE allows for a file handle to an unnamed file in the filesystem.
|
||||
// This can help reduce contention within the namespace (parent directories),
|
||||
// etc. And will auto cleanup the inode on close if we never link this
|
||||
// file descriptor into the namespace.
|
||||
fd, err := unix.Open(dir, unix.O_RDWR|unix.O_TMPFILE|unix.O_CLOEXEC, defaultFilePerm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// for O_TMPFILE, filename is /proc/self/fd/<fd> to be used
|
||||
// later to link file into namespace
|
||||
f := os.NewFile(uintptr(fd), filepath.Join(procfddir, strconv.Itoa(fd)))
|
||||
|
||||
tmp := &tmpfile{
|
||||
f: f,
|
||||
bucket: bucket,
|
||||
objname: obj,
|
||||
size: size,
|
||||
needsChown: doChown,
|
||||
uid: uid,
|
||||
gid: gid,
|
||||
newDirPerm: s.newDirPerm,
|
||||
}
|
||||
|
||||
if doChown {
|
||||
err := f.Chown(uid, gid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set temp file ownership: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) link() error {
|
||||
// We use Linkat/Rename as the atomic operation for object puts. The
|
||||
// upload is written to a temp (or unnamed/O_TMPFILE) file to not conflict
|
||||
// with any other simultaneous uploads. The final operation is to move the
|
||||
// temp file into place for the object. This ensures the object semantics
|
||||
// of last upload completed wins and is not some combination of writes
|
||||
// from simultaneous uploads.
|
||||
objPath := filepath.Join(tmp.bucket, tmp.objname)
|
||||
err := os.Remove(objPath)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove stale path: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(objPath)
|
||||
|
||||
err = backend.MkdirAll(dir, tmp.uid, tmp.gid, tmp.needsChown, tmp.newDirPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("make parent dir: %w", err)
|
||||
}
|
||||
|
||||
procdir, err := os.Open(procfddir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open proc dir: %w", err)
|
||||
}
|
||||
defer procdir.Close()
|
||||
|
||||
dirf, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open parent dir: %w", err)
|
||||
}
|
||||
defer dirf.Close()
|
||||
|
||||
err = unix.Linkat(int(procdir.Fd()), filepath.Base(tmp.f.Name()),
|
||||
int(dirf.Fd()), filepath.Base(objPath), unix.AT_SYMLINK_FOLLOW)
|
||||
if err != nil {
|
||||
return fmt.Errorf("link tmpfile: %w", err)
|
||||
}
|
||||
|
||||
err = tmp.f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("close tmpfile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) Write(b []byte) (int, error) {
|
||||
if int64(len(b)) > tmp.size {
|
||||
return 0, fmt.Errorf("write exceeds content length %v", tmp.size)
|
||||
}
|
||||
|
||||
n, err := tmp.f.Write(b)
|
||||
tmp.size -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) cleanup() {
|
||||
tmp.f.Close()
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) File() *os.File {
|
||||
return tmp.f
|
||||
}
|
||||
|
||||
func moveData(from *os.File, to *os.File) error {
|
||||
return scoutfs.MoveData(from, to)
|
||||
// May fail if the files are not 4K aligned; check for alignment
|
||||
ffi, err := from.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat from: %v", err)
|
||||
}
|
||||
tfi, err := to.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat to: %v", err)
|
||||
}
|
||||
if ffi.Size()%4096 != 0 || tfi.Size()%4096 != 0 {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
err = scoutfs.MoveData(from, to)
|
||||
if err != nil {
|
||||
debuglogger.Logf("ScoutFs MoveData failed: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func statMore(path string) (stat, error) {
|
||||
|
||||
@@ -20,44 +20,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/versity/versitygw/auth"
|
||||
)
|
||||
|
||||
func New(rootdir string, opts ScoutfsOpts) (*ScoutFS, error) {
|
||||
return nil, fmt.Errorf("scoutfs only available on linux")
|
||||
}
|
||||
|
||||
type tmpfile struct{}
|
||||
|
||||
var (
|
||||
errNotSupported = errors.New("not supported")
|
||||
)
|
||||
|
||||
func (s *ScoutFS) openTmpFile(_, _, _ string, _ int64, _ auth.Account) (*tmpfile, error) {
|
||||
// make these look used for static check
|
||||
_ = s.chownuid
|
||||
_ = s.chowngid
|
||||
_ = s.euid
|
||||
_ = s.egid
|
||||
return nil, errNotSupported
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) link() error {
|
||||
return errNotSupported
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) Write(b []byte) (int, error) {
|
||||
return 0, errNotSupported
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) cleanup() {
|
||||
}
|
||||
|
||||
func (tmp *tmpfile) File() *os.File {
|
||||
return nil
|
||||
}
|
||||
|
||||
func moveData(_, _ *os.File) error {
|
||||
return errNotSupported
|
||||
}
|
||||
|
||||
1304
backend/walk.go
1304
backend/walk.go
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,22 @@ func TestWalk(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "max objs",
|
||||
delimiter: "/",
|
||||
prefix: "photos/2006/February/",
|
||||
maxObjs: 2,
|
||||
expected: backend.WalkResults{
|
||||
Objects: []s3response.Object{
|
||||
{
|
||||
Key: backend.GetPtrFromString("photos/2006/February/sample2.jpg"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("photos/2006/February/sample3.jpg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -226,7 +242,7 @@ func TestWalk(t *testing.T) {
|
||||
tt.fsys, tc.prefix, tc.delimiter, tc.marker, tc.maxObjs,
|
||||
tt.getobj, []string{})
|
||||
if err != nil {
|
||||
t.Errorf("tc.name: walk: %v", err)
|
||||
t.Errorf("%v: walk: %v", tc.name, err)
|
||||
}
|
||||
|
||||
compareResults(tc.name, res, tc.expected, t)
|
||||
@@ -376,3 +392,702 @@ func TestWalkStop(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderWalk tests the lexicographic ordering of the object names
|
||||
// for the case where readdir sort order of a directory is different
|
||||
// than the lexicographic ordering of the full paths. The below has
|
||||
// a readdir sort order for dir1/:
|
||||
// a, a.b
|
||||
// but if you consider the character that comes after a is "/", then
|
||||
// the "." should come before "/" in the lexicographic ordering:
|
||||
// a.b/, a/
|
||||
func TestOrderWalk(t *testing.T) {
|
||||
tests := []walkTest{
|
||||
{
|
||||
fsys: fstest.MapFS{
|
||||
"dir1/a/file1": {},
|
||||
"dir1/a/file2": {},
|
||||
"dir1/a/file3": {},
|
||||
"dir1/a.b/file1": {},
|
||||
"dir1/a.b/file2": {},
|
||||
},
|
||||
getobj: getObj,
|
||||
cases: []testcase{
|
||||
{
|
||||
name: "order test",
|
||||
maxObjs: 1000,
|
||||
prefix: "dir1/",
|
||||
expected: backend.WalkResults{
|
||||
Objects: []s3response.Object{
|
||||
{Key: backend.GetPtrFromString("dir1/")},
|
||||
{Key: backend.GetPtrFromString("dir1/a.b/")},
|
||||
{Key: backend.GetPtrFromString("dir1/a.b/file1")},
|
||||
{Key: backend.GetPtrFromString("dir1/a.b/file2")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/file1")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/file2")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/file3")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fsys: fstest.MapFS{
|
||||
"dir|1/a/file1": {},
|
||||
"dir|1/a/file2": {},
|
||||
"dir|1/a/file3": {},
|
||||
"dir|1/a.b/file1": {},
|
||||
"dir|1/a.b/file2": {},
|
||||
},
|
||||
getobj: getObj,
|
||||
cases: []testcase{
|
||||
{
|
||||
name: "order test delim",
|
||||
maxObjs: 1000,
|
||||
delimiter: "|",
|
||||
prefix: "dir|",
|
||||
expected: backend.WalkResults{
|
||||
Objects: []s3response.Object{
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir|1/a.b/file1"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir|1/a.b/file2"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir|1/a/file1"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir|1/a/file2"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir|1/a/file3"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fsys: fstest.MapFS{
|
||||
"a": &fstest.MapFile{Mode: fs.ModeDir},
|
||||
},
|
||||
getobj: getObj,
|
||||
cases: []testcase{
|
||||
{
|
||||
name: "single dir obj",
|
||||
maxObjs: 1000,
|
||||
delimiter: "/",
|
||||
prefix: "a",
|
||||
expected: backend.WalkResults{
|
||||
CommonPrefixes: []types.CommonPrefix{
|
||||
{
|
||||
Prefix: backend.GetPtrFromString("a/"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single dir obj",
|
||||
maxObjs: 1000,
|
||||
delimiter: "/",
|
||||
prefix: "a/",
|
||||
expected: backend.WalkResults{
|
||||
Objects: []s3response.Object{
|
||||
{
|
||||
Key: backend.GetPtrFromString("a/"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, tc := range tt.cases {
|
||||
res, err := backend.Walk(context.Background(),
|
||||
tt.fsys, tc.prefix, tc.delimiter, tc.marker, tc.maxObjs,
|
||||
tt.getobj, []string{})
|
||||
if err != nil {
|
||||
t.Errorf("%v: walk: %v", tc.name, err)
|
||||
}
|
||||
|
||||
compareResultsOrdered(tc.name, res, tc.expected, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type markerTest struct {
|
||||
fsys fs.FS
|
||||
getobj backend.GetObjFunc
|
||||
cases []markertestcase
|
||||
}
|
||||
|
||||
type markertestcase struct {
|
||||
name string
|
||||
prefix string
|
||||
delimiter string
|
||||
marker string
|
||||
maxObjs int32
|
||||
expected []backend.WalkResults
|
||||
}
|
||||
|
||||
func TestMarker(t *testing.T) {
|
||||
tests := []markerTest{
|
||||
{
|
||||
fsys: fstest.MapFS{
|
||||
"dir/sample2.jpg": {},
|
||||
"dir/sample3.jpg": {},
|
||||
"dir/sample4.jpg": {},
|
||||
"dir/sample5.jpg": {},
|
||||
},
|
||||
getobj: getObj,
|
||||
cases: []markertestcase{
|
||||
{
|
||||
name: "multi page marker",
|
||||
delimiter: "/",
|
||||
prefix: "dir/",
|
||||
maxObjs: 2,
|
||||
expected: []backend.WalkResults{
|
||||
{
|
||||
Objects: []s3response.Object{
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir/sample2.jpg"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir/sample3.jpg"),
|
||||
},
|
||||
},
|
||||
Truncated: true,
|
||||
},
|
||||
{
|
||||
Objects: []s3response.Object{
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir/sample4.jpg"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir/sample5.jpg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fsys: fstest.MapFS{
|
||||
"dir1/subdir/file.txt": {},
|
||||
"dir1/subdir.ext": {},
|
||||
"dir1/subdir1.ext": {},
|
||||
"dir1/subdir2.ext": {},
|
||||
},
|
||||
getobj: getObj,
|
||||
cases: []markertestcase{
|
||||
{
|
||||
name: "integration test case 1",
|
||||
maxObjs: 2,
|
||||
delimiter: "/",
|
||||
prefix: "dir1/",
|
||||
expected: []backend.WalkResults{
|
||||
{
|
||||
Objects: []s3response.Object{
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir1/subdir.ext"),
|
||||
},
|
||||
},
|
||||
CommonPrefixes: []types.CommonPrefix{
|
||||
{
|
||||
Prefix: backend.GetPtrFromString("dir1/subdir/"),
|
||||
},
|
||||
},
|
||||
Truncated: true,
|
||||
},
|
||||
{
|
||||
Objects: []s3response.Object{
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir1/subdir1.ext"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("dir1/subdir2.ext"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fsys: fstest.MapFS{
|
||||
"asdf": {},
|
||||
"boo/bar": {},
|
||||
"boo/baz/xyzzy": {},
|
||||
"cquux/thud": {},
|
||||
"cquux/bla": {},
|
||||
},
|
||||
getobj: getObj,
|
||||
cases: []markertestcase{
|
||||
{
|
||||
name: "integration test case2",
|
||||
maxObjs: 1,
|
||||
delimiter: "/",
|
||||
marker: "boo/",
|
||||
expected: []backend.WalkResults{
|
||||
{
|
||||
Objects: []s3response.Object{},
|
||||
CommonPrefixes: []types.CommonPrefix{
|
||||
{
|
||||
Prefix: backend.GetPtrFromString("cquux/"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fsys: fstest.MapFS{
|
||||
"bar": {},
|
||||
"baz": {},
|
||||
"foo": {},
|
||||
},
|
||||
getobj: getObj,
|
||||
cases: []markertestcase{
|
||||
{
|
||||
name: "exact limit count",
|
||||
maxObjs: 3,
|
||||
expected: []backend.WalkResults{
|
||||
{
|
||||
Objects: []s3response.Object{
|
||||
{
|
||||
Key: backend.GetPtrFromString("bar"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("baz"),
|
||||
},
|
||||
{
|
||||
Key: backend.GetPtrFromString("foo"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fsys: fstest.MapFS{
|
||||
"d1/f1": {},
|
||||
"d2/f2": {},
|
||||
"d3/f3": {},
|
||||
"d4/f4": {},
|
||||
},
|
||||
getobj: getObj,
|
||||
cases: []markertestcase{
|
||||
{
|
||||
name: "limited common prefix",
|
||||
maxObjs: 3,
|
||||
delimiter: "/",
|
||||
expected: []backend.WalkResults{
|
||||
{
|
||||
CommonPrefixes: []types.CommonPrefix{
|
||||
{
|
||||
Prefix: backend.GetPtrFromString("d1/"),
|
||||
},
|
||||
{
|
||||
Prefix: backend.GetPtrFromString("d2/"),
|
||||
},
|
||||
{
|
||||
Prefix: backend.GetPtrFromString("d3/"),
|
||||
},
|
||||
},
|
||||
Truncated: true,
|
||||
},
|
||||
{
|
||||
CommonPrefixes: []types.CommonPrefix{
|
||||
{
|
||||
Prefix: backend.GetPtrFromString("d4/"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, tc := range tt.cases {
|
||||
marker := tc.marker
|
||||
for i, page := range tc.expected {
|
||||
res, err := backend.Walk(context.Background(),
|
||||
tt.fsys, tc.prefix, tc.delimiter, marker, tc.maxObjs,
|
||||
tt.getobj, []string{})
|
||||
if err != nil {
|
||||
t.Errorf("%v: walk: %v", tc.name, err)
|
||||
}
|
||||
marker = res.NextMarker
|
||||
|
||||
compareResultsOrdered(tc.name, res, page, t)
|
||||
|
||||
if res.Truncated != page.Truncated {
|
||||
t.Errorf("%v page %v expected truncated %v, got %v",
|
||||
tc.name, i, page.Truncated, res.Truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compareResultsOrdered(name string, got, wanted backend.WalkResults, t *testing.T) {
|
||||
if !compareObjectsOrdered(got.Objects, wanted.Objects) {
|
||||
t.Errorf("%v: unexpected object, got %v wanted %v",
|
||||
name,
|
||||
printObjects(got.Objects),
|
||||
printObjects(wanted.Objects))
|
||||
}
|
||||
if !comparePrefixesOrdered(got.CommonPrefixes, wanted.CommonPrefixes) {
|
||||
t.Errorf("%v: unexpected prefix, got %v wanted %v",
|
||||
name,
|
||||
printCommonPrefixes(got.CommonPrefixes),
|
||||
printCommonPrefixes(wanted.CommonPrefixes))
|
||||
}
|
||||
}
|
||||
|
||||
func compareObjectsOrdered(a, b []s3response.Object) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i, obj := range a {
|
||||
if *obj.Key != *b[i].Key {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func comparePrefixesOrdered(a, b []types.CommonPrefix) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i, cp := range a {
|
||||
if *cp.Prefix != *b[i].Prefix {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Versioning Tests ----
|
||||
|
||||
// getVersionsTestFunc is a simple GetVersionsFunc implementation for tests that
|
||||
// returns a single latest version for each file or directory encountered.
|
||||
// Directories are reported with a trailing delimiter in the key to match the
|
||||
// behavior of the non-versioned Walk tests where directory objects are listed.
|
||||
func getVersionsTestFunc(path, versionIdMarker string, pastVersionIdMarker *bool, availableObjCount int, d fs.DirEntry) (*backend.ObjVersionFuncResult, error) {
|
||||
// If we have no available slots left, signal truncation (should be rare in these tests)
|
||||
if availableObjCount <= 0 {
|
||||
return &backend.ObjVersionFuncResult{Truncated: true, NextVersionIdMarker: ""}, nil
|
||||
}
|
||||
|
||||
key := path
|
||||
if d.IsDir() {
|
||||
key = key + "/"
|
||||
}
|
||||
ver := "v1"
|
||||
latest := true
|
||||
ov := s3response.ObjectVersion{Key: &key, VersionId: &ver, IsLatest: &latest}
|
||||
return &backend.ObjVersionFuncResult{ObjectVersions: []s3response.ObjectVersion{ov}}, nil
|
||||
}
|
||||
|
||||
// TestWalkVersions mirrors TestWalk but exercises WalkVersions and validates
|
||||
// common prefixes and object versions for typical delimiter/prefix scenarios.
|
||||
func TestWalkVersions(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"dir1/a/file1": {},
|
||||
"dir1/a/file2": {},
|
||||
"dir1/b/file3": {},
|
||||
"rootfile": {},
|
||||
}
|
||||
|
||||
// Without a delimiter, every directory and file becomes an object version
|
||||
// via the test GetVersionsFunc (directories have trailing '/').
|
||||
expected := backend.WalkVersioningResults{
|
||||
ObjectVersions: []s3response.ObjectVersion{
|
||||
{Key: backend.GetPtrFromString("dir1/")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/file1")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/file2")},
|
||||
{Key: backend.GetPtrFromString("dir1/b/")},
|
||||
{Key: backend.GetPtrFromString("dir1/b/file3")},
|
||||
{Key: backend.GetPtrFromString("rootfile")},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := backend.WalkVersions(context.Background(), fsys, "", "", "", "", 1000, getVersionsTestFunc, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf("walk versions: %v", err)
|
||||
}
|
||||
compareVersionResultsOrdered("simple versions no delimiter", res, expected, t)
|
||||
}
|
||||
|
||||
// TestOrderWalkVersions mirrors TestOrderWalk, exercising ordering semantics for
|
||||
// version listings (lexicographic ordering of directory and file version keys).
|
||||
func TestOrderWalkVersions(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"dir1/a/file1": {},
|
||||
"dir1/a/file2": {},
|
||||
"dir1/a/file3": {},
|
||||
"dir1/a.b/file1": {},
|
||||
"dir1/a.b/file2": {},
|
||||
}
|
||||
|
||||
// Expect lexicographic ordering similar to non-version walk when no delimiter.
|
||||
expected := backend.WalkVersioningResults{
|
||||
ObjectVersions: []s3response.ObjectVersion{
|
||||
{Key: backend.GetPtrFromString("dir1/")},
|
||||
{Key: backend.GetPtrFromString("dir1/a.b/")},
|
||||
{Key: backend.GetPtrFromString("dir1/a.b/file1")},
|
||||
{Key: backend.GetPtrFromString("dir1/a.b/file2")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/file1")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/file2")},
|
||||
{Key: backend.GetPtrFromString("dir1/a/file3")},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := backend.WalkVersions(context.Background(), fsys, "dir1/", "", "", "", 1000, getVersionsTestFunc, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf("order walk versions: %v", err)
|
||||
}
|
||||
compareVersionResultsOrdered("order versions no delimiter", res, expected, t)
|
||||
}
|
||||
|
||||
// compareVersionResults compares unordered sets of common prefixes and object versions
|
||||
// compareVersionResultsOrdered compares ordered slices
|
||||
func compareVersionResultsOrdered(name string, got, wanted backend.WalkVersioningResults, t *testing.T) {
|
||||
if !compareObjectVersionsOrdered(got.ObjectVersions, wanted.ObjectVersions) {
|
||||
t.Errorf("%v: unexpected object versions, got %v wanted %v", name, printVersionObjects(got.ObjectVersions), printVersionObjects(wanted.ObjectVersions))
|
||||
}
|
||||
if !comparePrefixesOrdered(got.CommonPrefixes, wanted.CommonPrefixes) {
|
||||
t.Errorf("%v: unexpected prefix, got %v wanted %v", name, printCommonPrefixes(got.CommonPrefixes), printCommonPrefixes(wanted.CommonPrefixes))
|
||||
}
|
||||
}
|
||||
|
||||
func compareObjectVersionsOrdered(a, b []s3response.ObjectVersion) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i, ov := range a {
|
||||
if ov.Key == nil || b[i].Key == nil {
|
||||
return false
|
||||
}
|
||||
if *ov.Key != *b[i].Key {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func printVersionObjects(list []s3response.ObjectVersion) string {
|
||||
res := "["
|
||||
for _, ov := range list {
|
||||
var key string
|
||||
if ov.Key == nil {
|
||||
key = "<nil>"
|
||||
} else {
|
||||
key = *ov.Key
|
||||
}
|
||||
if res == "[" {
|
||||
res = res + key
|
||||
} else {
|
||||
res = res + ", " + key
|
||||
}
|
||||
}
|
||||
return res + "]"
|
||||
}
|
||||
|
||||
// multiVersionGetVersionsFunc is a more sophisticated test function that simulates
|
||||
// multiple versions per object, similar to the integration test behavior.
|
||||
// It creates multiple versions for each file with deterministic version IDs.
|
||||
func createMultiVersionFunc(files map[string]int) backend.GetVersionsFunc {
|
||||
// Pre-generate all versions for deterministic testing
|
||||
versionedFiles := make(map[string][]s3response.ObjectVersion)
|
||||
|
||||
for path, versionCount := range files {
|
||||
versions := make([]s3response.ObjectVersion, versionCount)
|
||||
for i := range versionCount {
|
||||
versionId := fmt.Sprintf("v%d", i+1)
|
||||
isLatest := i == versionCount-1 // Last version is latest
|
||||
key := path
|
||||
|
||||
versions[i] = s3response.ObjectVersion{
|
||||
Key: &key,
|
||||
VersionId: &versionId,
|
||||
IsLatest: &isLatest,
|
||||
}
|
||||
}
|
||||
// Reverse slice so latest comes first (reverse chronological order)
|
||||
for i, j := 0, len(versions)-1; i < j; i, j = i+1, j-1 {
|
||||
versions[i], versions[j] = versions[j], versions[i]
|
||||
}
|
||||
versionedFiles[path] = versions
|
||||
}
|
||||
|
||||
return func(path, versionIdMarker string, pastVersionIdMarker *bool, availableObjCount int, d fs.DirEntry) (*backend.ObjVersionFuncResult, error) {
|
||||
if availableObjCount <= 0 {
|
||||
return &backend.ObjVersionFuncResult{Truncated: true}, nil
|
||||
}
|
||||
|
||||
// Handle directories - just return a single directory version
|
||||
if d.IsDir() {
|
||||
key := path + "/"
|
||||
ver := "v1"
|
||||
latest := true
|
||||
ov := s3response.ObjectVersion{Key: &key, VersionId: &ver, IsLatest: &latest}
|
||||
return &backend.ObjVersionFuncResult{ObjectVersions: []s3response.ObjectVersion{ov}}, nil
|
||||
}
|
||||
|
||||
// Get versions for this file
|
||||
versions, exists := versionedFiles[path]
|
||||
if !exists {
|
||||
// No versions for this file, skip it
|
||||
return &backend.ObjVersionFuncResult{}, backend.ErrSkipObj
|
||||
}
|
||||
|
||||
// Handle version ID marker pagination
|
||||
startIdx := 0
|
||||
if versionIdMarker != "" && !*pastVersionIdMarker {
|
||||
// Find the starting position after the marker
|
||||
for i, version := range versions {
|
||||
if *version.VersionId == versionIdMarker {
|
||||
startIdx = i + 1
|
||||
*pastVersionIdMarker = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return available versions up to the limit
|
||||
endIdx := min(startIdx+availableObjCount, len(versions))
|
||||
|
||||
result := &backend.ObjVersionFuncResult{
|
||||
ObjectVersions: versions[startIdx:endIdx],
|
||||
}
|
||||
|
||||
// Check if we need to truncate
|
||||
if endIdx < len(versions) {
|
||||
result.Truncated = true
|
||||
result.NextVersionIdMarker = *versions[endIdx-1].VersionId
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalkVersionsTruncated tests the pagination behavior of WalkVersions
|
||||
// when there are multiple versions per object and the result is truncated.
|
||||
// This mirrors the integration test ListObjectVersions_multiple_object_versions_truncated.
|
||||
func TestWalkVersionsTruncated(t *testing.T) {
|
||||
// Create filesystem with the same files as integration test
|
||||
fsys := fstest.MapFS{
|
||||
"foo": {},
|
||||
"bar": {},
|
||||
"baz": {},
|
||||
}
|
||||
|
||||
// Define version counts per file (matching integration test)
|
||||
versionCounts := map[string]int{
|
||||
"foo": 4, // 4 versions
|
||||
"bar": 3, // 3 versions
|
||||
"baz": 5, // 5 versions
|
||||
}
|
||||
|
||||
getVersionsFunc := createMultiVersionFunc(versionCounts)
|
||||
|
||||
// Test first page with limit of 5 (should be truncated)
|
||||
maxKeys := 5
|
||||
res1, err := backend.WalkVersions(context.Background(), fsys, "", "", "", "", maxKeys, getVersionsFunc, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf("walk versions first page: %v", err)
|
||||
}
|
||||
|
||||
// Verify first page results
|
||||
if !res1.Truncated {
|
||||
t.Error("expected first page to be truncated")
|
||||
}
|
||||
|
||||
if len(res1.ObjectVersions) != maxKeys {
|
||||
t.Errorf("expected %d versions in first page, got %d", maxKeys, len(res1.ObjectVersions))
|
||||
}
|
||||
|
||||
// Expected order: bar (3 versions), baz (2 versions) - lexicographic order
|
||||
expectedFirstPage := []string{"bar", "bar", "bar", "baz", "baz"}
|
||||
if len(res1.ObjectVersions) != len(expectedFirstPage) {
|
||||
t.Fatalf("first page length mismatch: expected %d, got %d", len(expectedFirstPage), len(res1.ObjectVersions))
|
||||
}
|
||||
|
||||
for i, expected := range expectedFirstPage {
|
||||
if res1.ObjectVersions[i].Key == nil || *res1.ObjectVersions[i].Key != expected {
|
||||
t.Errorf("first page[%d]: expected key %s, got %v", i, expected, res1.ObjectVersions[i].Key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify next markers are set
|
||||
if res1.NextMarker == "" {
|
||||
t.Error("expected NextMarker to be set on truncated result")
|
||||
}
|
||||
if res1.NextVersionIdMarker == "" {
|
||||
t.Error("expected NextVersionIdMarker to be set on truncated result")
|
||||
}
|
||||
|
||||
// Test second page using markers
|
||||
res2, err := backend.WalkVersions(context.Background(), fsys, "", "", res1.NextMarker, res1.NextVersionIdMarker, maxKeys, getVersionsFunc, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf("walk versions second page: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Second page: ObjectVersions=%d, Truncated=%v, NextMarker=%s, NextVersionIdMarker=%s",
|
||||
len(res2.ObjectVersions), res2.Truncated, res2.NextMarker, res2.NextVersionIdMarker)
|
||||
|
||||
for i, ov := range res2.ObjectVersions {
|
||||
t.Logf(" [%d] Key=%s, VersionId=%s", i, *ov.Key, *ov.VersionId)
|
||||
}
|
||||
|
||||
// Verify second page results
|
||||
// With maxKeys=5, we should have 3 pages total: 5 + 5 + 2 = 12
|
||||
|
||||
// Test third page if needed
|
||||
var res3 backend.WalkVersioningResults
|
||||
if res2.Truncated {
|
||||
res3, err = backend.WalkVersions(context.Background(), fsys, "", "", res2.NextMarker, res2.NextVersionIdMarker, maxKeys, getVersionsFunc, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf("walk versions third page: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Third page: ObjectVersions=%d, Truncated=%v, NextMarker=%s, NextVersionIdMarker=%s",
|
||||
len(res3.ObjectVersions), res3.Truncated, res3.NextMarker, res3.NextVersionIdMarker)
|
||||
|
||||
for i, ov := range res3.ObjectVersions {
|
||||
t.Logf(" [%d] Key=%s, VersionId=%s", i, *ov.Key, *ov.VersionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify total count across all pages
|
||||
totalVersions := len(res1.ObjectVersions) + len(res2.ObjectVersions) + len(res3.ObjectVersions)
|
||||
expectedTotal := versionCounts["foo"] + versionCounts["bar"] + versionCounts["baz"]
|
||||
if totalVersions != expectedTotal {
|
||||
t.Errorf("total versions mismatch: expected %d, got %d", expectedTotal, totalVersions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,11 @@ func adminCommand() *cli.Command {
|
||||
Usage: "secret access key for the new user",
|
||||
Aliases: []string{"s"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "role",
|
||||
Usage: "the new user role",
|
||||
Aliases: []string{"r"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "user-id",
|
||||
Usage: "userID for the new user",
|
||||
@@ -311,8 +316,14 @@ func deleteUser(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
func updateUser(ctx *cli.Context) error {
|
||||
access, secret, userId, groupId := ctx.String("access"), ctx.String("secret"), ctx.Int("user-id"), ctx.Int("group-id")
|
||||
access, secret, userId, groupId, role := ctx.String("access"), ctx.String("secret"), ctx.Int("user-id"), ctx.Int("group-id"), auth.Role(ctx.String("role"))
|
||||
props := auth.MutableProps{}
|
||||
if ctx.IsSet("role") {
|
||||
if !role.IsValid() {
|
||||
return fmt.Errorf("invalid user role: %v", role)
|
||||
}
|
||||
props.Role = role
|
||||
}
|
||||
if ctx.IsSet("secret") {
|
||||
props.Secret = &secret
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/metrics"
|
||||
"github.com/versity/versitygw/s3api"
|
||||
"github.com/versity/versitygw/s3api/middlewares"
|
||||
@@ -45,12 +46,16 @@ var (
|
||||
certFile, keyFile string
|
||||
kafkaURL, kafkaTopic, kafkaKey string
|
||||
natsURL, natsTopic string
|
||||
rabbitmqURL, rabbitmqExchange string
|
||||
rabbitmqRoutingKey string
|
||||
eventWebhookURL string
|
||||
eventConfigFilePath string
|
||||
logWebhookURL, accessLog string
|
||||
adminLogFile string
|
||||
healthPath string
|
||||
virtualDomain string
|
||||
debug bool
|
||||
keepAlive bool
|
||||
pprof string
|
||||
quiet bool
|
||||
readonly bool
|
||||
@@ -60,14 +65,14 @@ var (
|
||||
ldapAccessAtr, ldapSecAtr, ldapRoleAtr string
|
||||
ldapUserIdAtr, ldapGroupIdAtr string
|
||||
vaultEndpointURL, vaultSecretStoragePath string
|
||||
vaultMountPath, vaultRootToken string
|
||||
vaultRoleId, vaultRoleSecret string
|
||||
vaultServerCert, vaultClientCert string
|
||||
vaultClientCertKey string
|
||||
vaultAuthMethod, vaultMountPath string
|
||||
vaultRootToken, vaultRoleId string
|
||||
vaultRoleSecret, vaultServerCert string
|
||||
vaultClientCert, vaultClientCertKey string
|
||||
s3IamAccess, s3IamSecret string
|
||||
s3IamRegion, s3IamBucket string
|
||||
s3IamEndpoint string
|
||||
s3IamSslNoVerify, s3IamDebug bool
|
||||
s3IamSslNoVerify bool
|
||||
iamCacheDisable bool
|
||||
iamCacheTTL int
|
||||
iamCachePrune int
|
||||
@@ -76,7 +81,8 @@ var (
|
||||
dogstatsServers string
|
||||
ipaHost, ipaVaultName string
|
||||
ipaUser, ipaPassword string
|
||||
ipaInsecure, ipaDebug bool
|
||||
ipaInsecure bool
|
||||
iamDebug bool
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -98,6 +104,7 @@ func main() {
|
||||
scoutfsCommand(),
|
||||
s3Command(),
|
||||
azureCommand(),
|
||||
pluginCommand(),
|
||||
adminCommand(),
|
||||
testCommand(),
|
||||
utilsCommand(),
|
||||
@@ -219,6 +226,12 @@ func initFlags() []cli.Flag {
|
||||
EnvVars: []string{"VGW_PPROF"},
|
||||
Destination: &pprof,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "keep-alive",
|
||||
Usage: "enable keep-alive connections (for finnicky clients)",
|
||||
EnvVars: []string{"VGW_KEEP_ALIVE"},
|
||||
Destination: &keepAlive,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "quiet",
|
||||
Usage: "silence stdout request logging output",
|
||||
@@ -226,6 +239,13 @@ func initFlags() []cli.Flag {
|
||||
Destination: &quiet,
|
||||
Aliases: []string{"q"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "virtual-domain",
|
||||
Usage: "enables the virtual host style bucket addressing with the specified arg as the base domain",
|
||||
EnvVars: []string{"VGW_VIRTUAL_DOMAIN"},
|
||||
Destination: &virtualDomain,
|
||||
Aliases: []string{"vd"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "access-log",
|
||||
Usage: "enable server access logging to specified file",
|
||||
@@ -279,6 +299,27 @@ func initFlags() []cli.Flag {
|
||||
Destination: &natsTopic,
|
||||
Aliases: []string{"ent"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "event-rabbitmq-url",
|
||||
Usage: "rabbitmq server url to send the bucket notifications (amqp or amqps scheme)",
|
||||
EnvVars: []string{"VGW_EVENT_RABBITMQ_URL"},
|
||||
Destination: &rabbitmqURL,
|
||||
Aliases: []string{"eru"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "event-rabbitmq-exchange",
|
||||
Usage: "rabbitmq exchange to publish bucket notifications to (blank for default)",
|
||||
EnvVars: []string{"VGW_EVENT_RABBITMQ_EXCHANGE"},
|
||||
Destination: &rabbitmqExchange,
|
||||
Aliases: []string{"ere"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "event-rabbitmq-routing-key",
|
||||
Usage: "rabbitmq routing key when publishing bucket notifications (defaults to bucket name when blank)",
|
||||
EnvVars: []string{"VGW_EVENT_RABBITMQ_ROUTING_KEY"},
|
||||
Destination: &rabbitmqRoutingKey,
|
||||
Aliases: []string{"errk"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "event-webhook-url",
|
||||
Usage: "webhook url to send bucket notifications",
|
||||
@@ -371,6 +412,12 @@ func initFlags() []cli.Flag {
|
||||
EnvVars: []string{"VGW_IAM_VAULT_SECRET_STORAGE_PATH"},
|
||||
Destination: &vaultSecretStoragePath,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "iam-vault-auth-method",
|
||||
Usage: "vault server auth method",
|
||||
EnvVars: []string{"VGW_IAM_VAULT_AUTH_METHOD"},
|
||||
Destination: &vaultAuthMethod,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "iam-vault-mount-path",
|
||||
Usage: "vault server mount path",
|
||||
@@ -450,12 +497,6 @@ func initFlags() []cli.Flag {
|
||||
EnvVars: []string{"VGW_S3_IAM_NO_VERIFY"},
|
||||
Destination: &s3IamSslNoVerify,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "s3-iam-debug",
|
||||
Usage: "s3 IAM debug output",
|
||||
EnvVars: []string{"VGW_S3_IAM_DEBUG"},
|
||||
Destination: &s3IamDebug,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "iam-cache-disable",
|
||||
Usage: "disable local iam cache",
|
||||
@@ -476,6 +517,13 @@ func initFlags() []cli.Flag {
|
||||
Value: 3600,
|
||||
Destination: &iamCachePrune,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "iam-debug",
|
||||
Usage: "enable IAM debug output",
|
||||
Value: false,
|
||||
EnvVars: []string{"VGW_IAM_DEBUG"},
|
||||
Destination: &iamDebug,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "health",
|
||||
Usage: `health check endpoint path. Health endpoint will be configured on GET http method: GET <health>
|
||||
@@ -524,28 +572,22 @@ func initFlags() []cli.Flag {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ipa-user",
|
||||
Usage: "Username used to connect to FreeIPA. Needs permissions to read user vault contents",
|
||||
Usage: "Username used to connect to FreeIPA (requires permissions to read user vault contents)",
|
||||
EnvVars: []string{"VGW_IPA_USER"},
|
||||
Destination: &ipaUser,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ipa-password",
|
||||
Usage: "Password of the user used to connect to FreeIPA.",
|
||||
Usage: "Password of the user used to connect to FreeIPA",
|
||||
EnvVars: []string{"VGW_IPA_PASSWORD"},
|
||||
Destination: &ipaPassword,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ipa-insecure",
|
||||
Usage: "Verify TLS certificate of FreeIPA server. Default is 'true'.",
|
||||
Usage: "Disable verify TLS certificate of FreeIPA server",
|
||||
EnvVars: []string{"VGW_IPA_INSECURE"},
|
||||
Destination: &ipaInsecure,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ipa-debug",
|
||||
Usage: "FreeIPA IAM debug output",
|
||||
EnvVars: []string{"VGW_IPA_DEBUG"},
|
||||
Destination: &ipaDebug,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +608,7 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
AppName: "versitygw",
|
||||
ServerHeader: "VERSITYGW",
|
||||
StreamRequestBody: true,
|
||||
DisableKeepalive: true,
|
||||
DisableKeepalive: !keepAlive,
|
||||
Network: fiber.NetworkTCP,
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
@@ -587,9 +629,6 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
}
|
||||
opts = append(opts, s3api.WithTLS(cert))
|
||||
}
|
||||
if debug {
|
||||
opts = append(opts, s3api.WithDebug())
|
||||
}
|
||||
if admPort == "" {
|
||||
opts = append(opts, s3api.WithAdminServer())
|
||||
}
|
||||
@@ -602,29 +641,16 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
if readonly {
|
||||
opts = append(opts, s3api.WithReadOnly())
|
||||
}
|
||||
if virtualDomain != "" {
|
||||
opts = append(opts, s3api.WithHostStyle(virtualDomain))
|
||||
}
|
||||
|
||||
admApp := fiber.New(fiber.Config{
|
||||
AppName: "versitygw",
|
||||
ServerHeader: "VERSITYGW",
|
||||
Network: fiber.NetworkTCP,
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
if debug {
|
||||
debuglogger.SetDebugEnabled()
|
||||
}
|
||||
|
||||
var admOpts []s3api.AdminOpt
|
||||
|
||||
if admCertFile != "" || admKeyFile != "" {
|
||||
if admCertFile == "" {
|
||||
return fmt.Errorf("TLS key specified without cert file")
|
||||
}
|
||||
if admKeyFile == "" {
|
||||
return fmt.Errorf("TLS cert specified without key file")
|
||||
}
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(admCertFile, admKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls: load certs: %v", err)
|
||||
}
|
||||
admOpts = append(admOpts, s3api.WithAdminSrvTLS(cert))
|
||||
if iamDebug {
|
||||
debuglogger.SetIAMDebugEnabled()
|
||||
}
|
||||
|
||||
iam, err := auth.New(&auth.Opts{
|
||||
@@ -646,6 +672,7 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
LDAPGroupIdAtr: ldapGroupIdAtr,
|
||||
VaultEndpointURL: vaultEndpointURL,
|
||||
VaultSecretStoragePath: vaultSecretStoragePath,
|
||||
VaultAuthMethod: vaultAuthMethod,
|
||||
VaultMountPath: vaultMountPath,
|
||||
VaultRootToken: vaultRootToken,
|
||||
VaultRoleId: vaultRoleId,
|
||||
@@ -659,7 +686,6 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
S3Bucket: s3IamBucket,
|
||||
S3Endpoint: s3IamEndpoint,
|
||||
S3DisableSSlVerfiy: s3IamSslNoVerify,
|
||||
S3Debug: s3IamDebug,
|
||||
CacheDisable: iamCacheDisable,
|
||||
CacheTTL: iamCacheTTL,
|
||||
CachePrune: iamCachePrune,
|
||||
@@ -668,7 +694,6 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
IpaUser: ipaUser,
|
||||
IpaPassword: ipaPassword,
|
||||
IpaInsecure: ipaInsecure,
|
||||
IpaDebug: ipaDebug,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("setup iam: %w", err)
|
||||
@@ -698,6 +723,9 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
KafkaTopicKey: kafkaKey,
|
||||
NatsURL: natsURL,
|
||||
NatsTopic: natsTopic,
|
||||
RabbitmqURL: rabbitmqURL,
|
||||
RabbitmqExchange: rabbitmqExchange,
|
||||
RabbitmqRoutingKey: rabbitmqRoutingKey,
|
||||
WebhookURL: eventWebhookURL,
|
||||
FilterConfigFilePath: eventConfigFilePath,
|
||||
})
|
||||
@@ -713,7 +741,41 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
return fmt.Errorf("init gateway: %v", err)
|
||||
}
|
||||
|
||||
admSrv := s3api.NewAdminServer(admApp, be, middlewares.RootUserConfig{Access: rootUserAccess, Secret: rootUserSecret}, admPort, region, iam, loggers.AdminLogger, admOpts...)
|
||||
var admSrv *s3api.S3AdminServer
|
||||
|
||||
if admPort != "" {
|
||||
admApp := fiber.New(fiber.Config{
|
||||
AppName: "versitygw",
|
||||
ServerHeader: "VERSITYGW",
|
||||
Network: fiber.NetworkTCP,
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
|
||||
var opts []s3api.AdminOpt
|
||||
|
||||
if admCertFile != "" || admKeyFile != "" {
|
||||
if admCertFile == "" {
|
||||
return fmt.Errorf("TLS key specified without cert file")
|
||||
}
|
||||
if admKeyFile == "" {
|
||||
return fmt.Errorf("TLS cert specified without key file")
|
||||
}
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(admCertFile, admKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls: load certs: %v", err)
|
||||
}
|
||||
opts = append(opts, s3api.WithAdminSrvTLS(cert))
|
||||
}
|
||||
if quiet {
|
||||
opts = append(opts, s3api.WithAdminQuiet())
|
||||
}
|
||||
if debug {
|
||||
opts = append(opts, s3api.WithAdminDebug())
|
||||
}
|
||||
|
||||
admSrv = s3api.NewAdminServer(admApp, be, middlewares.RootUserConfig{Access: rootUserAccess, Secret: rootUserSecret}, admPort, region, iam, loggers.AdminLogger, opts...)
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
printBanner(port, admPort, certFile != "", admCertFile != "")
|
||||
@@ -958,10 +1020,7 @@ func getMatchingIPs(spec string) ([]string, error) {
|
||||
const columnWidth = 70
|
||||
|
||||
func centerText(text string) string {
|
||||
padding := (columnWidth - 2 - len(text)) / 2
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
padding := max((columnWidth-2-len(text))/2, 0)
|
||||
return strings.Repeat(" ", padding) + text
|
||||
}
|
||||
|
||||
|
||||
74
cmd/versitygw/plugin.go
Normal file
74
cmd/versitygw/plugin.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright 2025 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"plugin"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/versity/versitygw/plugins"
|
||||
)
|
||||
|
||||
func pluginCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "plugin",
|
||||
Usage: "load a backend from a plugin",
|
||||
Description: "Runs a s3 gateway and redirects the requests to the backend defined in the plugin",
|
||||
Action: runPluginBackend,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "config",
|
||||
Usage: "location of the config file",
|
||||
Aliases: []string{"c"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginBackend(ctx *cli.Context) error {
|
||||
if ctx.NArg() == 0 {
|
||||
return fmt.Errorf("no plugin file provided to be loaded")
|
||||
}
|
||||
|
||||
pluginPath := ctx.Args().Get(0)
|
||||
config := ctx.String("config")
|
||||
|
||||
p, err := plugin.Open(pluginPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backendSymbol, err := p.Lookup("Backend")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backendPluginPtr, ok := backendSymbol.(*plugins.BackendPlugin)
|
||||
if !ok {
|
||||
return errors.New("plugin is not of type *plugins.BackendPlugin")
|
||||
}
|
||||
|
||||
if backendPluginPtr == nil {
|
||||
return errors.New("variable Backend is nil")
|
||||
}
|
||||
|
||||
be, err := (*backendPluginPtr).New(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runGateway(ctx.Context, be)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ var (
|
||||
dirPerms uint
|
||||
sidecar string
|
||||
nometa bool
|
||||
forceNoTmpFile bool
|
||||
)
|
||||
|
||||
func posixCommand() *cli.Command {
|
||||
@@ -93,6 +94,12 @@ will be translated into the file /mnt/fs/gwroot/mybucket/a/b/c/myobject`,
|
||||
EnvVars: []string{"VGW_META_NONE"},
|
||||
Destination: &nometa,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "disableotmp",
|
||||
Usage: "disable O_TMPFILE support for new objects",
|
||||
EnvVars: []string{"VGW_DISABLE_OTMP"},
|
||||
Destination: &forceNoTmpFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -113,11 +120,12 @@ func runPosix(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
opts := posix.PosixOpts{
|
||||
ChownUID: chownuid,
|
||||
ChownGID: chowngid,
|
||||
BucketLinks: bucketlinks,
|
||||
VersioningDir: versioningDir,
|
||||
NewDirPerm: fs.FileMode(dirPerms),
|
||||
ChownUID: chownuid,
|
||||
ChownGID: chowngid,
|
||||
BucketLinks: bucketlinks,
|
||||
VersioningDir: versioningDir,
|
||||
NewDirPerm: fs.FileMode(dirPerms),
|
||||
ForceNoTmpFile: forceNoTmpFile,
|
||||
}
|
||||
|
||||
var ms meta.MetadataStorer
|
||||
|
||||
@@ -26,8 +26,10 @@ var (
|
||||
s3proxySecret string
|
||||
s3proxyEndpoint string
|
||||
s3proxyRegion string
|
||||
s3proxyMetaBucket string
|
||||
s3proxyDisableChecksum bool
|
||||
s3proxySslSkipVerify bool
|
||||
s3proxyUsePathStyle bool
|
||||
s3proxyDebug bool
|
||||
)
|
||||
|
||||
@@ -71,6 +73,12 @@ to an s3 storage backend service.`,
|
||||
EnvVars: []string{"VGW_S3_REGION"},
|
||||
Destination: &s3proxyRegion,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "meta-bucket",
|
||||
Usage: "s3 service meta bucket to store buckets acl/policy",
|
||||
EnvVars: []string{"VGW_S3_META_BUCKET"},
|
||||
Destination: &s3proxyMetaBucket,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "disable-checksum",
|
||||
Usage: "disable gateway to server object checksums",
|
||||
@@ -85,6 +93,13 @@ to an s3 storage backend service.`,
|
||||
Value: false,
|
||||
Destination: &s3proxySslSkipVerify,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "use-path-style",
|
||||
Usage: "use path style addressing for s3 proxy",
|
||||
EnvVars: []string{"VGW_S3_USE_PATH_STYLE"},
|
||||
Value: false,
|
||||
Destination: &s3proxyUsePathStyle,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "output extra debug tracing",
|
||||
@@ -97,8 +112,8 @@ to an s3 storage backend service.`,
|
||||
}
|
||||
|
||||
func runS3(ctx *cli.Context) error {
|
||||
be, err := s3proxy.New(s3proxyAccess, s3proxySecret, s3proxyEndpoint, s3proxyRegion,
|
||||
s3proxyDisableChecksum, s3proxySslSkipVerify, s3proxyDebug)
|
||||
be, err := s3proxy.New(ctx.Context, s3proxyAccess, s3proxySecret, s3proxyEndpoint, s3proxyRegion,
|
||||
s3proxyMetaBucket, s3proxyDisableChecksum, s3proxySslSkipVerify, s3proxyUsePathStyle, s3proxyDebug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init s3 backend: %w", err)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,12 @@ move interfaces as well as support for tiered filesystems.`,
|
||||
EnvVars: []string{"VGW_BUCKET_LINKS"},
|
||||
Destination: &bucketlinks,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "versioning-dir",
|
||||
Usage: "the directory path to enable bucket versioning",
|
||||
EnvVars: []string{"VGW_VERSIONING_DIR"},
|
||||
Destination: &versioningDir,
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "dir-perms",
|
||||
Usage: "default directory permissions for new directories",
|
||||
@@ -106,6 +112,7 @@ func runScoutfs(ctx *cli.Context) error {
|
||||
opts.BucketLinks = bucketlinks
|
||||
opts.NewDirPerm = fs.FileMode(dirPerms)
|
||||
opts.DisableNoArchive = disableNoArchive
|
||||
opts.VersioningDir = versioningDir
|
||||
|
||||
be, err := scoutfs.New(ctx.Args().Get(0), opts)
|
||||
if err != nil {
|
||||
|
||||
@@ -34,7 +34,7 @@ var (
|
||||
totalReqs int
|
||||
upload bool
|
||||
download bool
|
||||
pathStyle bool
|
||||
hostStyle bool
|
||||
checksumDisable bool
|
||||
versioningEnabled bool
|
||||
azureTests bool
|
||||
@@ -74,6 +74,12 @@ func initTestFlags() []cli.Flag {
|
||||
Destination: &endpoint,
|
||||
Aliases: []string{"e"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "host-style",
|
||||
Usage: "Use host-style bucket addressing",
|
||||
Value: false,
|
||||
Destination: &hostStyle,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "enable debug mode",
|
||||
@@ -124,6 +130,11 @@ func initTestCommands() []*cli.Command {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "scoutfs",
|
||||
Usage: "Tests scoutfs full flow",
|
||||
Action: getAction(integration.TestScoutfs),
|
||||
},
|
||||
{
|
||||
Name: "iam",
|
||||
Usage: "Tests iam service",
|
||||
@@ -186,12 +197,6 @@ func initTestCommands() []*cli.Command {
|
||||
Value: 1,
|
||||
Destination: &concurrency,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "pathStyle",
|
||||
Usage: "Use Pathstyle bucket addressing",
|
||||
Value: false,
|
||||
Destination: &pathStyle,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "checksumDis",
|
||||
Usage: "Disable server checksum",
|
||||
@@ -223,8 +228,8 @@ func initTestCommands() []*cli.Command {
|
||||
if debug {
|
||||
opts = append(opts, integration.WithDebug())
|
||||
}
|
||||
if pathStyle {
|
||||
opts = append(opts, integration.WithPathStyle())
|
||||
if hostStyle {
|
||||
opts = append(opts, integration.WithHostStyle())
|
||||
}
|
||||
if checksumDisable {
|
||||
opts = append(opts, integration.WithDisableChecksum())
|
||||
@@ -287,6 +292,9 @@ func initTestCommands() []*cli.Command {
|
||||
if checksumDisable {
|
||||
opts = append(opts, integration.WithDisableChecksum())
|
||||
}
|
||||
if hostStyle {
|
||||
opts = append(opts, integration.WithHostStyle())
|
||||
}
|
||||
|
||||
s3conf := integration.NewS3Conf(opts...)
|
||||
|
||||
@@ -316,6 +324,9 @@ func getAction(tf testFunc) func(*cli.Context) error {
|
||||
if azureTests {
|
||||
opts = append(opts, integration.WithAzureMode())
|
||||
}
|
||||
if hostStyle {
|
||||
opts = append(opts, integration.WithHostStyle())
|
||||
}
|
||||
|
||||
s := integration.NewS3Conf(opts...)
|
||||
tf(s)
|
||||
@@ -351,6 +362,9 @@ func extractIntTests() (commands []*cli.Command) {
|
||||
if versioningEnabled {
|
||||
opts = append(opts, integration.WithVersioningEnabled())
|
||||
}
|
||||
if hostStyle {
|
||||
opts = append(opts, integration.WithHostStyle())
|
||||
}
|
||||
|
||||
s := integration.NewS3Conf(opts...)
|
||||
err := testFunc(s)
|
||||
|
||||
253
debuglogger/logger.go
Normal file
253
debuglogger/logger.go
Normal file
@@ -0,0 +1,253 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package debuglogger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type Color string
|
||||
|
||||
const (
|
||||
green Color = "\033[32m"
|
||||
yellow Color = "\033[33m"
|
||||
blue Color = "\033[34m"
|
||||
Purple Color = "\033[0;35m"
|
||||
|
||||
reset = "\033[0m"
|
||||
borderChar = "─"
|
||||
boxWidth = 120
|
||||
)
|
||||
|
||||
// Logs http request details: headers, body, params, query args
|
||||
func LogFiberRequestDetails(ctx *fiber.Ctx) {
|
||||
// Log the full request url
|
||||
fullURL := ctx.Protocol() + "://" + ctx.Hostname() + ctx.OriginalURL()
|
||||
fmt.Printf("%s[URL]: %s%s\n", green, fullURL, reset)
|
||||
|
||||
// log request headers
|
||||
wrapInBox(green, "REQUEST HEADERS", boxWidth, func() {
|
||||
for key, value := range ctx.Request().Header.All() {
|
||||
printWrappedLine(yellow, string(key), string(value))
|
||||
}
|
||||
})
|
||||
// skip request body log for PutObject and UploadPart
|
||||
skipBodyLog := isLargeDataAction(ctx)
|
||||
if !skipBodyLog {
|
||||
body := ctx.Request().Body()
|
||||
if len(body) != 0 {
|
||||
printBoxTitleLine(blue, "REQUEST BODY", boxWidth, false)
|
||||
fmt.Printf("%s%s%s\n", blue, body, reset)
|
||||
printHorizontalBorder(blue, boxWidth, false)
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Request().URI().QueryArgs().Len() != 0 {
|
||||
for key, value := range ctx.Request().URI().QueryArgs().All() {
|
||||
log.Printf("%s: %s", key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logs http response details: body, headers
|
||||
func LogFiberResponseDetails(ctx *fiber.Ctx) {
|
||||
wrapInBox(green, "RESPONSE HEADERS", boxWidth, func() {
|
||||
for key, value := range ctx.Response().Header.All() {
|
||||
printWrappedLine(yellow, string(key), string(value))
|
||||
}
|
||||
})
|
||||
|
||||
_, ok := ctx.Locals("skip-res-body-log").(bool)
|
||||
if !ok {
|
||||
body := ctx.Response().Body()
|
||||
if len(body) != 0 {
|
||||
PrintInsideHorizontalBorders(blue, "RESPONSE BODY", string(body), boxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var debugEnabled atomic.Bool
|
||||
|
||||
// SetDebugEnabled sets the debug mode
|
||||
func SetDebugEnabled() {
|
||||
debugEnabled.Store(true)
|
||||
}
|
||||
|
||||
// IsDebugEnabled returns true if debugging is enabled
|
||||
func IsDebugEnabled() bool {
|
||||
return debugEnabled.Load()
|
||||
}
|
||||
|
||||
// Logf is the same as 'fmt.Printf' with debug prefix,
|
||||
// a color added and '\n' at the end
|
||||
func Logf(format string, v ...any) {
|
||||
if !debugEnabled.Load() {
|
||||
return
|
||||
}
|
||||
debugPrefix := "[DEBUG]: "
|
||||
fmt.Printf(string(yellow)+debugPrefix+format+reset+"\n", v...)
|
||||
}
|
||||
|
||||
// Infof prints out green info block with [INFO]: prefix
|
||||
func Infof(format string, v ...any) {
|
||||
if !debugEnabled.Load() {
|
||||
return
|
||||
}
|
||||
debugPrefix := "[INFO]: "
|
||||
fmt.Printf(string(green)+debugPrefix+format+reset+"\n", v...)
|
||||
}
|
||||
|
||||
var debugIAMEnabled atomic.Bool
|
||||
|
||||
// SetIAMDebugEnabled sets the IAM debug mode
|
||||
func SetIAMDebugEnabled() {
|
||||
debugIAMEnabled.Store(true)
|
||||
}
|
||||
|
||||
// IsDebugEnabled returns true if debugging enabled
|
||||
func IsIAMDebugEnabled() bool {
|
||||
return debugEnabled.Load()
|
||||
}
|
||||
|
||||
// IAMLogf is the same as 'fmt.Printf' with debug prefix,
|
||||
// a color added and '\n' at the end
|
||||
func IAMLogf(format string, v ...any) {
|
||||
if !debugIAMEnabled.Load() {
|
||||
return
|
||||
}
|
||||
debugPrefix := "[DEBUG]: "
|
||||
fmt.Printf(string(yellow)+debugPrefix+format+reset+"\n", v...)
|
||||
}
|
||||
|
||||
// PrintInsideHorizontalBorders prints the text inside horizontal
|
||||
// border and title in the center of upper border
|
||||
func PrintInsideHorizontalBorders(color Color, title, text string, width int) {
|
||||
if !debugEnabled.Load() {
|
||||
return
|
||||
}
|
||||
printBoxTitleLine(color, title, width, false)
|
||||
fmt.Printf("%s%s%s\n", color, text, reset)
|
||||
printHorizontalBorder(color, width, false)
|
||||
}
|
||||
|
||||
// Prints out box title either with closing characters or not: "┌", "┐"
|
||||
// e.g ┌────────────────[ RESPONSE HEADERS ]────────────────┐
|
||||
func printBoxTitleLine(color Color, title string, length int, closing bool) {
|
||||
leftCorner, rightCorner := "┌", "┐"
|
||||
|
||||
if !closing {
|
||||
leftCorner, rightCorner = borderChar, borderChar
|
||||
}
|
||||
|
||||
// Calculate how many border characters are needed
|
||||
titleFormatted := fmt.Sprintf("[ %s ]", title)
|
||||
borderSpace := length - len(titleFormatted) - 2 // 2 for corners
|
||||
leftLen := borderSpace / 2
|
||||
rightLen := borderSpace - leftLen
|
||||
|
||||
// Build the line
|
||||
line := leftCorner +
|
||||
strings.Repeat(borderChar, leftLen) +
|
||||
titleFormatted +
|
||||
strings.Repeat(borderChar, rightLen) +
|
||||
rightCorner
|
||||
|
||||
fmt.Println(string(color) + line + reset)
|
||||
}
|
||||
|
||||
// Prints out a horizontal line either with closing characters or not: "└", "┘"
|
||||
func printHorizontalBorder(color Color, length int, closing bool) {
|
||||
leftCorner, rightCorner := "└", "┘"
|
||||
if !closing {
|
||||
leftCorner, rightCorner = borderChar, borderChar
|
||||
}
|
||||
|
||||
line := leftCorner + strings.Repeat(borderChar, length-2) + rightCorner + reset
|
||||
fmt.Println(string(color) + line)
|
||||
}
|
||||
|
||||
// wrapInBox wraps the output of a function call (fn) inside a styled box with a title.
|
||||
func wrapInBox(color Color, title string, length int, fn func()) {
|
||||
printBoxTitleLine(color, title, length, true)
|
||||
fn()
|
||||
printHorizontalBorder(color, length, true)
|
||||
}
|
||||
|
||||
// returns the provided string length
|
||||
// defaulting to 13 for exceeding lengths
|
||||
func getLen(str string) int {
|
||||
if len(str) < 13 {
|
||||
return 13
|
||||
}
|
||||
|
||||
return len(str)
|
||||
}
|
||||
|
||||
// prints a formatted key-value pair within a box layout,
|
||||
// wrapping the value text if it exceeds the allowed width.
|
||||
func printWrappedLine(keyColor Color, key, value string) {
|
||||
prefix := fmt.Sprintf("%s│%s %s%-13s%s : ", green, reset, keyColor, key, reset)
|
||||
prefixLen := len(prefix) - len(green) - len(reset) - len(keyColor) - len(reset)
|
||||
// the actual prefix size without colors
|
||||
actualPrefixLen := getLen(key) + 5
|
||||
|
||||
lineWidth := boxWidth - prefixLen
|
||||
valueLines := wrapText(value, lineWidth)
|
||||
|
||||
for i, line := range valueLines {
|
||||
if i == 0 {
|
||||
if len(line) < lineWidth {
|
||||
line += strings.Repeat(" ", lineWidth-len(line))
|
||||
}
|
||||
fmt.Printf("%s%s%s %s│%s\n", prefix, reset, line, green, reset)
|
||||
} else {
|
||||
line = strings.Repeat(" ", actualPrefixLen-2) + line
|
||||
if len(line) < boxWidth-4 {
|
||||
line += strings.Repeat(" ", boxWidth-len(line)-4)
|
||||
}
|
||||
fmt.Printf("%s│ %s%s %s│%s\n", green, reset, line, green, reset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapText splits the input text into lines of at most `width` characters each.
|
||||
func wrapText(text string, width int) []string {
|
||||
var lines []string
|
||||
for len(text) > width {
|
||||
lines = append(lines, text[:width])
|
||||
text = text[width:]
|
||||
}
|
||||
if text != "" {
|
||||
lines = append(lines, text)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// TODO: remove this and use utils.IsBidDataAction after refactoring
|
||||
// and creating 'internal' package
|
||||
func isLargeDataAction(ctx *fiber.Ctx) bool {
|
||||
if ctx.Method() == http.MethodPut && len(strings.Split(ctx.Path(), "/")) >= 3 {
|
||||
if !ctx.Request().URI().QueryArgs().Has("tagging") && ctx.Get("X-Amz-Copy-Source") == "" && !ctx.Request().URI().QueryArgs().Has("acl") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -99,6 +99,26 @@ ROOT_SECRET_ACCESS_KEY=
|
||||
# endpoint is unauthenticated, and returns a 200 status for GET.
|
||||
#VGW_HEALTH=
|
||||
|
||||
# Enable VGW_READ_ONLY to only allow read operations to the S3 server. No write
|
||||
# operations will be allowed.
|
||||
#VGW_READ_ONLY=false
|
||||
|
||||
# The VGW_VIRTUAL_DOMAIN option enables the virtual host style bucket
|
||||
# addressing. The path style addressing is the default, and remains enabled
|
||||
# even when virtual host style is enabled. The VGW_VIRTUAL_DOMAIN option
|
||||
# specifies the domain name that will be used for the virtual host style
|
||||
# addressing. For virtual addressing, access to a bucket is in the request
|
||||
# form:
|
||||
# https://<bucket>.<VGW_VIRTUAL_DOMAIN>/
|
||||
# for example: https://mybucket.example.com/ where
|
||||
# VGW_VIRTUAL_DOMAIN=example.com
|
||||
# and all subdomains of VGW_VIRTUAL_DOMAIN should be reserved for buckets.
|
||||
# This means that virtual host addressing will generally require a DNS
|
||||
# entry for each bucket that needs to be accessed.
|
||||
# The default path style request is of the form:
|
||||
# https://<VGW_ENDPOINT>/<bucket>
|
||||
#VGW_VIRTUAL_DOMAIN=
|
||||
|
||||
###############
|
||||
# Access Logs #
|
||||
###############
|
||||
@@ -149,6 +169,19 @@ ROOT_SECRET_ACCESS_KEY=
|
||||
#VGW_EVENT_NATS_URL=
|
||||
#VGW_EVENT_NATS_TOPIC=
|
||||
|
||||
# Bucket events can be sent to a RabbitMQ messaging service. When
|
||||
# VGW_EVENT_RABBITMQ_URL is specified, events will be published to the specified
|
||||
# exchange (VGW_EVENT_RABBITMQ_EXCHANGE) using the routing key
|
||||
# (VGW_EVENT_RABBITMQ_ROUTING_KEY). If exchange is blank the default exchange is
|
||||
# used. If routing key is blank, it will be left empty (the server can bind a
|
||||
# queue with an empty binding key or you can set an explicit key).
|
||||
# Example URL formats:
|
||||
# amqp://user:pass@rabbitmq:5672/
|
||||
# amqps://user:pass@rabbitmq:5671/vhost
|
||||
#VGW_EVENT_RABBITMQ_URL=
|
||||
#VGW_EVENT_RABBITMQ_EXCHANGE=
|
||||
#VGW_EVENT_RABBITMQ_ROUTING_KEY=
|
||||
|
||||
# Bucket events can be sent to a webhook. When VGW_EVENT_WEBHOOK_URL is
|
||||
# specified, all configured bucket events will be sent to the webhook.
|
||||
#VGW_EVENT_WEBHOOK_URL=
|
||||
@@ -240,6 +273,24 @@ ROOT_SECRET_ACCESS_KEY=
|
||||
#VGW_IAM_LDAP_USER_ID_ATR=
|
||||
#VGW_IAM_LDAP_GROUP_ID_ATR=
|
||||
|
||||
# The FreeIPA options will enable the FreeIPA IAM service with accounts stored
|
||||
# in an external FreeIPA service. Currently the FreeIPA IAM service only
|
||||
# supports account retrieval. Creating and modifying accounts must be done
|
||||
# outside of the versitygw service.
|
||||
# FreeIPA server url e.g. https://ipa.example.test
|
||||
#VGW_IPA_HOST=
|
||||
# A name of the user vault containing their secret
|
||||
#VGW_IPA_VAULT_NAME=
|
||||
# Username used to connect to FreeIPA (requires permissions to read user vault
|
||||
# contents)
|
||||
#VGW_IPA_USER=
|
||||
# Password of the user used to connect to FreeIPA
|
||||
#VGW_IPA_PASSWORD=
|
||||
# Disable verify TLS certificate of FreeIPA server
|
||||
#VGW_IPA_INSECURE=false
|
||||
# FreeIPA IAM debug output
|
||||
#VGW_IPA_DEBUG=false
|
||||
|
||||
###############
|
||||
# IAM caching #
|
||||
###############
|
||||
@@ -317,6 +368,40 @@ ROOT_SECRET_ACCESS_KEY=
|
||||
# as any parent directories automatically created with object uploads.
|
||||
#VGW_DIR_PERMS=0755
|
||||
|
||||
# To enable object versions, the VGW_VERSIONING_DIR option must be set to the
|
||||
# directory that will be used to store the object versions. The version
|
||||
# directory must NOT be a subdirectory of the VGW_BACKEND_ARG directory.
|
||||
#VGW_VERSIONING_DIR=
|
||||
|
||||
# The gateway uses xattrs to store metadata for objects by default. For systems
|
||||
# that do not support xattrs, the VGW_META_SIDECAR option can be set to a
|
||||
# directory that will be used to store the metadata for objects. This is
|
||||
# currently experimental, and may have issues for some edge cases.
|
||||
#VGW_META_SIDECAR=
|
||||
|
||||
# The VGW_META_NONE option will disable the metadata functionality for the
|
||||
# gateway. This will cause the gateway to not store any metadata for objects
|
||||
# or buckets. This include bucket ACLs and Policy. This may be useful for
|
||||
# read only access to pre-existing data where the gateway should not modify
|
||||
# the data. It is recommened to enable VGW_READ_ONLY (Global Options) along
|
||||
# with this.
|
||||
#VGW_META_NONE=false
|
||||
|
||||
# The gateway will use O_TMPFILE for writing objects while uploading and
|
||||
# link the file to the final object name when the upload is complete if the
|
||||
# filesystem supports O_TMPFILE. This creates an atomic object creation
|
||||
# that is not visible to other clients or racing uploads until the upload
|
||||
# is complete. This will not work if there is a different filesystem mounted
|
||||
# below the bucket level than where the bucket resides. The VGW_DISABLE_OTMP
|
||||
# option can be set to true to disable this functionality and force the fallback
|
||||
# mode when O_TMPFILE is not available. This fallback will create a temporary
|
||||
# file in the bucket directory and rename it to the final object name when
|
||||
# the upload is complete if the final location is in the same filesystem, or
|
||||
# copy the file to the final location if the final location is in a different
|
||||
# filesystem. This fallback mode is still atomic, but may be less efficient
|
||||
# than O_TMPFILE when the data needs to be copied into the final location.
|
||||
#VGW_DISABLE_OTMP=false
|
||||
|
||||
###########
|
||||
# scoutfs #
|
||||
###########
|
||||
@@ -358,6 +443,14 @@ ROOT_SECRET_ACCESS_KEY=
|
||||
# as any parent directories automatically created with object uploads.
|
||||
#VGW_DIR_PERMS=0755
|
||||
|
||||
# To enable object versions, the VGW_VERSIONING_DIR option must be set to the
|
||||
# directory that will be used to store the object versions. The version
|
||||
# directory must NOT be a subdirectory of the VGW_BACKEND_ARG directory.
|
||||
# There may be implications for archive policy updates to include version
|
||||
# directory as well. It is recommended to discuss archive implications of
|
||||
# versioning with Versity support before enabling on an archiving filesystem.
|
||||
#VGW_VERSIONING_DIR=
|
||||
|
||||
# The default behavior of the gateway is to automatically set the noarchive
|
||||
# flag on the multipart upload parts while the multipart upload is in progress.
|
||||
# This is to prevent the parts from being archived since they are temporary
|
||||
|
||||
95
go.mod
95
go.mod
@@ -1,77 +1,82 @@
|
||||
module github.com/versity/versitygw
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0
|
||||
github.com/DataDog/datadog-go/v5 v5.6.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.1
|
||||
github.com/aws/smithy-go v1.22.3
|
||||
github.com/go-ldap/ldap/v3 v3.4.10
|
||||
github.com/gofiber/fiber/v2 v2.52.6
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2
|
||||
github.com/DataDog/datadog-go/v5 v5.7.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.0
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.1
|
||||
github.com/aws/smithy-go v1.23.0
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
github.com/go-ldap/ldap/v3 v3.4.11
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/vault-client-go v0.4.3
|
||||
github.com/nats-io/nats.go v1.41.0
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/pkg/xattr v0.4.10
|
||||
github.com/segmentio/kafka-go v0.4.47
|
||||
github.com/nats-io/nats.go v1.45.0
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/pkg/xattr v0.4.12
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/segmentio/kafka-go v0.4.49
|
||||
github.com/smira/go-statsd v1.3.4
|
||||
github.com/urfave/cli/v2 v2.27.6
|
||||
github.com/valyala/fasthttp v1.60.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
github.com/valyala/fasthttp v1.66.0
|
||||
github.com/versity/scoutfs-go v0.0.0-20240325223134-38eb2f5f7d44
|
||||
golang.org/x/sync v0.13.0
|
||||
golang.org/x/sys v0.32.0
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/sys v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.18 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.4 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.10 // indirect
|
||||
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.13.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.13
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.66
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.71
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.8
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.12
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.6
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.7 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
278
go.sum
278
go.sum
@@ -1,106 +1,98 @@
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 h1:MhRfI58HblXzCtWEZCO0feHs8LweePB3s90r7WaR1KU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0/go.mod h1:okZ+ZURbArNdlJ+ptXoyHNuOETzOl1Oww19rm8I2WLA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 h1:Bg8m3nq/X1DeePkAbCfb6ml6F3F0IunEhE8TMh+lY48=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0 h1:PiSrjRPpkQNjrM8H0WwKMnZUdu1RGMtd/LdGKUrOo+c=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0/go.mod h1:oDrbWx4ewMylP7xHivfgixbfGBT6APAwsSoHRKotnIc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 h1:UXT0o77lXQrikd1kgwIPQOUect7EoR/+sbP4wQKdzxM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0/go.mod h1:cTvi54pg19DoT07ekoeMgE/taAwNtCShVeZqA+Iv2xI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 h1:FwladfywkNirM+FZYLBR2kBz5C8Tg0fw5w5Y7meRXWI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2/go.mod h1:vv5Ad0RrIoT1lJFdWBZwt4mB1+j+V8DUroixmKDTCdk=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/DataDog/datadog-go/v5 v5.6.0 h1:2oCLxjF/4htd55piM75baflj/KoE6VYS7alEUqFvRDw=
|
||||
github.com/DataDog/datadog-go/v5 v5.6.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/DataDog/datadog-go/v5 v5.7.1 h1:dNhEwKaO3LJhGYKajl2DjobArfa5R9YF72z3Dy+PH3k=
|
||||
github.com/DataDog/datadog-go/v5 v5.7.1/go.mod h1:CA9Ih6tb3jtxk+ps1xvTnxmhjr7ldE8TiwrZyrm31ss=
|
||||
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.13 h1:RgdPqWoE8nPpIekpVpDJsBckbqT4Liiaq9f35pbTh1Y=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.13/go.mod h1:NI28qs/IOUIRhsR7GQ/JdexoqRN9tDxkIrYZq0SOF44=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.66 h1:aKpEKaTy6n4CEJeYI1MNj97oSDLi4xro3UzQfwf5RWE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.66/go.mod h1:xQ5SusDmHb/fy55wU0QqTy0yNfLqxzec59YcsRZB+rI=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.71 h1:s43gLuY+zGmtpx+KybfFP4IckopmTfDOPdlf/L++N5I=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.71/go.mod h1:KH6wWmY3O3c/jVAjHk0MGzVAFDxkOSt42Eoe4ZO4ge0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.0 h1:xm5WV/2L4emMRmMjHFykqiA4M/ra0DJVSWUkDyBjbg4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.0/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.8 h1:kQjtOLlTU4m4A64TsRcqwNChhGCwaPBt+zCQt/oWsHU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.8/go.mod h1:QPpc7IgljrKwH0+E6/KolCgr4WPLerURiU592AYzfSY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.12 h1:zmc9e1q90wMn8wQbjryy8IwA6Q4XlaL9Bx2zIqdNNbk=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.12/go.mod h1:3VzdRDR5u3sSJRI4kYcOSIBbeYsgtVk7dG5R/U6qLWY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7 h1:Is2tPmieqGS2edBnmOJIbdvOA6Op+rRpaYR60iBAwXM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7/go.mod h1:F1i5V5421EGci570yABvpIXgRIBPb5JM+lSkHF6Dq5w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.6 h1:bByPm7VcaAgeT2+z5m0Lj5HDzm+g9AwbA3WFx2hPby0=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.6/go.mod h1:PhTe8fR8aFW0wDc6IV9BHeIzXhpv3q6AaVHnqiv5Pyc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7 h1:UCxq0X9O3xrlENdKf1r9eRJoKz/b0AfGkpp3a7FPlhg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7/go.mod h1:rHRoJUNUASj5Z/0eqI4w32vKvC7atoWR0jC+IkmVH8k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7 h1:Y6DTZUn7ZUC4th9FMBbo8LVE+1fyq3ofw+tRwkUd3PY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7/go.mod h1:x3XE6vMnU9QvHN/Wrx2s44kwzV2o2g5x/siw4ZUJ9g8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.1 h1:2Ku1xwAohSSXHR1tpAnyVDSQSxoDMA+/NZBytW+f4qg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.1/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.18 h1:xz7WvTMfSStb9Y8NpCT82FXLNC3QasqBfuAFHY4Pk5g=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.18/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
|
||||
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
|
||||
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.7 h1:BszAktdUo2xlzmYHjWMq70DqJ7cROM8iBd3f6hrpuMQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.7/go.mod h1:XJ1yHki/P7ZPuG4fd3f0Pg/dSGA2cTQBCLw82MH2H48=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.7 h1:zmZ8qvtE9chfhBPuKB2aQFxW5F/rpwXUgmcVCgQzqRw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.7/go.mod h1:vVYfbpd2l+pKqlSIDIOgouxNsGu5il9uDp0ooWb0jys=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7 h1:mLgc5QIgOy26qyh5bvW+nDoAppxgn3J2WV3m9ewq7+8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7/go.mod h1:wXb/eQnqt8mDQIQTTmcw58B5mYGxzLGZGK8PWNFZ0BA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.7 h1:u3VbDKUCWarWiU+aIUK4gjTr/wQFXV17y3hgNno9fcA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.7/go.mod h1:/OuMQwhSyRapYxq6ZNpPer8juGNrB4P5Oz8bZ2cgjQE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.1 h1:+RpGuaQ72qnU83qBKVwxkznewEdAGhIWo/PQCmkhhog=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.1/go.mod h1:xajPTguLoeQMAOE44AAP2RQoUhF8ey1g5IFHARv71po=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.3 h1:7PKX3VYsZ8LUWceVRuv0+PU+E7OtQb1lgmi5vmUE9CM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.3/go.mod h1:Ql6jE9kyyWI5JHn+61UT/Y5Z0oyVJGmgmJbZD5g4unY=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.4 h1:e0XBRn3AptQotkyBFrHAxFB8mDhAIOfsG+7KyJ0dg98=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.4/go.mod h1:XclEty74bsGBCr1s0VSaA11hQ4ZidK4viWK7rRfO88I=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.4 h1:PR00NXRYgY4FWHqOGx3fC3lhVKjsp1GdloDv2ynMSd8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.4/go.mod h1:Z+Gd23v97pX9zK97+tX4ppAgqCt3Z2dIXB02CtBncK8=
|
||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
||||
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
|
||||
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
|
||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc=
|
||||
@@ -117,11 +109,14 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs=
|
||||
github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -132,56 +127,57 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/nats-io/nats.go v1.41.0 h1:PzxEva7fflkd+n87OtQTXqCTyLfIIMFJBpyccHLE2Ko=
|
||||
github.com/nats-io/nats.go v1.41.0/go.mod h1:wV73x0FSI/orHPSYoyMeJB+KajMDoWyXmFaRrrYaaTo=
|
||||
github.com/nats-io/nkeys v0.4.10 h1:glmRrpCmYLHByYcePvnTBEAwawwapjCPMjy2huw20wc=
|
||||
github.com/nats-io/nkeys v0.4.10/go.mod h1:OjRrnIKnWBFl+s4YK5ChQfvHP2fxqZexrKJoVVyWB3U=
|
||||
github.com/nats-io/nats.go v1.45.0 h1:/wGPbnYXDM0pLKFjZTX+2JOw9TQPoIgTFrUaH97giwA=
|
||||
github.com/nats-io/nats.go v1.45.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA=
|
||||
github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
|
||||
github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||
github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0=
|
||||
github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg=
|
||||
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
|
||||
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/smira/go-statsd v1.3.4 h1:kBYWcLSGT+qC6JVbvfz48kX7mQys32fjDOPrfmsSx2c=
|
||||
github.com/smira/go-statsd v1.3.4/go.mod h1:RjdsESPgDODtg1VpVVf9MJrEW2Hw0wtRNbmB1CAhu6A=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
|
||||
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.60.0 h1:kBRYS0lOhVJ6V+bYN8PqAHELKHtXqwq9zNMLKx1MBsw=
|
||||
github.com/valyala/fasthttp v1.60.0/go.mod h1:iY4kDgV3Gc6EqhRZ8icqcmlG6bqhcDXfuHgTO4FXCvc=
|
||||
github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU=
|
||||
github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs=
|
||||
github.com/versity/scoutfs-go v0.0.0-20240325223134-38eb2f5f7d44 h1:Wx1o3pNrCzsHIIDyZ2MLRr6tF/1FhAr7HNDn80QqDWE=
|
||||
github.com/versity/scoutfs-go v0.0.0-20240325223134-38eb2f5f7d44/go.mod h1:gJsq73k+4685y+rbDIpPY8i/5GbsiwP6JFoFyUDB1fQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
@@ -195,50 +191,22 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBi
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -246,57 +214,27 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -24,57 +24,99 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
ActionUndetected = "ActionUnDetected"
|
||||
ActionAbortMultipartUpload = "s3_AbortMultipartUpload"
|
||||
ActionCompleteMultipartUpload = "s3_CompleteMultipartUpload"
|
||||
ActionCopyObject = "s3_CopyObject"
|
||||
ActionCreateBucket = "s3_CreateBucket"
|
||||
ActionCreateMultipartUpload = "s3_CreateMultipartUpload"
|
||||
ActionDeleteBucket = "s3_DeleteBucket"
|
||||
ActionDeleteBucketPolicy = "s3_DeleteBucketPolicy"
|
||||
ActionDeleteBucketTagging = "s3_DeleteBucketTagging"
|
||||
ActionDeleteObject = "s3_DeleteObject"
|
||||
ActionDeleteObjectTagging = "s3_DeleteObjectTagging"
|
||||
ActionDeleteObjects = "s3_DeleteObjects"
|
||||
ActionGetBucketAcl = "s3_GetBucketAcl"
|
||||
ActionGetBucketPolicy = "s3_GetBucketPolicy"
|
||||
ActionGetBucketTagging = "s3_GetBucketTagging"
|
||||
ActionGetBucketVersioning = "s3_GetBucketVersioning"
|
||||
ActionGetObject = "s3_GetObject"
|
||||
ActionGetObjectAcl = "s3_GetObjectAcl"
|
||||
ActionGetObjectAttributes = "s3_GetObjectAttributes"
|
||||
ActionGetObjectLegalHold = "s3_GetObjectLegalHold"
|
||||
ActionGetObjectLockConfiguration = "s3_GetObjectLockConfiguration"
|
||||
ActionGetObjectRetention = "s3_GetObjectRetention"
|
||||
ActionGetObjectTagging = "s3_GetObjectTagging"
|
||||
ActionHeadBucket = "s3_HeadBucket"
|
||||
ActionHeadObject = "s3_HeadObject"
|
||||
ActionListAllMyBuckets = "s3_ListAllMyBuckets"
|
||||
ActionListMultipartUploads = "s3_ListMultipartUploads"
|
||||
ActionListObjectVersions = "s3_ListObjectVersions"
|
||||
ActionListObjects = "s3_ListObjects"
|
||||
ActionListObjectsV2 = "s3_ListObjectsV2"
|
||||
ActionListParts = "s3_ListParts"
|
||||
ActionPutBucketAcl = "s3_PutBucketAcl"
|
||||
ActionPutBucketPolicy = "s3_PutBucketPolicy"
|
||||
ActionPutBucketTagging = "s3_PutBucketTagging"
|
||||
ActionPutBucketVersioning = "s3_PutBucketVersioning"
|
||||
ActionPutObject = "s3_PutObject"
|
||||
ActionPutObjectAcl = "s3_PutObjectAcl"
|
||||
ActionPutObjectLegalHold = "s3_PutObjectLegalHold"
|
||||
ActionPutObjectLockConfiguration = "s3_PutObjectLockConfiguration"
|
||||
ActionPutObjectRetention = "s3_PutObjectRetention"
|
||||
ActionPutObjectTagging = "s3_PutObjectTagging"
|
||||
ActionRestoreObject = "s3_RestoreObject"
|
||||
ActionSelectObjectContent = "s3_SelectObjectContent"
|
||||
ActionUploadPart = "s3_UploadPart"
|
||||
ActionUploadPartCopy = "s3_UploadPartCopy"
|
||||
ActionPutBucketOwnershipControls = "s3_PutBucketOwnershipControls"
|
||||
ActionGetBucketOwnershipControls = "s3_GetBucketOwnershipControls"
|
||||
ActionDeleteBucketOwnershipControls = "s3_DeleteBucketOwnershipControls"
|
||||
ActionPutBucketCors = "s3_PutBucketCors"
|
||||
ActionGetBucketCors = "s3_GetBucketCors"
|
||||
ActionDeleteBucketCors = "s3_DeleteBucketCors"
|
||||
ActionUndetected = "ActionUnDetected"
|
||||
ActionAbortMultipartUpload = "s3_AbortMultipartUpload"
|
||||
ActionCompleteMultipartUpload = "s3_CompleteMultipartUpload"
|
||||
ActionCopyObject = "s3_CopyObject"
|
||||
ActionCreateBucket = "s3_CreateBucket"
|
||||
ActionCreateMultipartUpload = "s3_CreateMultipartUpload"
|
||||
ActionDeleteBucket = "s3_DeleteBucket"
|
||||
ActionDeleteBucketPolicy = "s3_DeleteBucketPolicy"
|
||||
ActionDeleteBucketTagging = "s3_DeleteBucketTagging"
|
||||
ActionDeleteObject = "s3_DeleteObject"
|
||||
ActionDeleteObjectTagging = "s3_DeleteObjectTagging"
|
||||
ActionDeleteObjects = "s3_DeleteObjects"
|
||||
ActionGetBucketAcl = "s3_GetBucketAcl"
|
||||
ActionGetBucketPolicy = "s3_GetBucketPolicy"
|
||||
ActionGetBucketTagging = "s3_GetBucketTagging"
|
||||
ActionGetBucketVersioning = "s3_GetBucketVersioning"
|
||||
ActionGetObject = "s3_GetObject"
|
||||
ActionGetObjectAcl = "s3_GetObjectAcl"
|
||||
ActionGetObjectAttributes = "s3_GetObjectAttributes"
|
||||
ActionGetObjectLegalHold = "s3_GetObjectLegalHold"
|
||||
ActionGetObjectLockConfiguration = "s3_GetObjectLockConfiguration"
|
||||
ActionGetObjectRetention = "s3_GetObjectRetention"
|
||||
ActionGetObjectTagging = "s3_GetObjectTagging"
|
||||
ActionHeadBucket = "s3_HeadBucket"
|
||||
ActionHeadObject = "s3_HeadObject"
|
||||
ActionListAllMyBuckets = "s3_ListAllMyBuckets"
|
||||
ActionListMultipartUploads = "s3_ListMultipartUploads"
|
||||
ActionListObjectVersions = "s3_ListObjectVersions"
|
||||
ActionListObjects = "s3_ListObjects"
|
||||
ActionListObjectsV2 = "s3_ListObjectsV2"
|
||||
ActionListParts = "s3_ListParts"
|
||||
ActionPutBucketAcl = "s3_PutBucketAcl"
|
||||
ActionPutBucketPolicy = "s3_PutBucketPolicy"
|
||||
ActionPutBucketTagging = "s3_PutBucketTagging"
|
||||
ActionPutBucketVersioning = "s3_PutBucketVersioning"
|
||||
ActionPutObject = "s3_PutObject"
|
||||
ActionPutObjectAcl = "s3_PutObjectAcl"
|
||||
ActionPutObjectLegalHold = "s3_PutObjectLegalHold"
|
||||
ActionPutObjectLockConfiguration = "s3_PutObjectLockConfiguration"
|
||||
ActionPutObjectRetention = "s3_PutObjectRetention"
|
||||
ActionPutObjectTagging = "s3_PutObjectTagging"
|
||||
ActionRestoreObject = "s3_RestoreObject"
|
||||
ActionSelectObjectContent = "s3_SelectObjectContent"
|
||||
ActionUploadPart = "s3_UploadPart"
|
||||
ActionUploadPartCopy = "s3_UploadPartCopy"
|
||||
ActionPutBucketOwnershipControls = "s3_PutBucketOwnershipControls"
|
||||
ActionGetBucketOwnershipControls = "s3_GetBucketOwnershipControls"
|
||||
ActionDeleteBucketOwnershipControls = "s3_DeleteBucketOwnershipControls"
|
||||
ActionPutBucketCors = "s3_PutBucketCors"
|
||||
ActionGetBucketCors = "s3_GetBucketCors"
|
||||
ActionDeleteBucketCors = "s3_DeleteBucketCors"
|
||||
ActionOptions = "s3_Options"
|
||||
ActionPutBucketAnalyticsConfiguration = "s3_PutBucketAnalyticsConfiguration"
|
||||
ActionGetBucketAnalyticsConfiguration = "s3_GetBucketAnalyticsConfiguration"
|
||||
ActionListBucketAnalyticsConfigurations = "s3_ListBucketAnalyticsConfigurations"
|
||||
ActionDeleteBucketAnalyticsConfiguration = "s3_DeleteBucketAnalyticsConfiguration"
|
||||
ActionPutBucketEncryption = "s3_PutBucketEncryption"
|
||||
ActionGetBucketEncryption = "s3_GetBucketEncryption"
|
||||
ActionDeleteBucketEncryption = "s3_DeleteBucketEncryption"
|
||||
ActionPutBucketIntelligentTieringConfiguration = "s3_PutBucketIntelligentTieringConfiguration"
|
||||
ActionGetBucketIntelligentTieringConfiguration = "s3_GetBucketIntelligentTieringConfiguration"
|
||||
ActionListBucketIntelligentTieringConfigurations = "s3_ListBucketIntelligentTieringConfigurations"
|
||||
ActionDeleteBucketIntelligentTieringConfiguration = "s3_DeleteBucketIntelligentTieringConfiguration"
|
||||
ActionPutBucketInventoryConfiguration = "s3_PutBucketInventoryConfiguration"
|
||||
ActionGetBucketInventoryConfiguration = "s3_GetBucketInventoryConfiguration"
|
||||
ActionListBucketInventoryConfigurations = "s3_ListBucketInventoryConfigurations"
|
||||
ActionDeleteBucketInventoryConfiguration = "s3_DeleteBucketInventoryConfiguration"
|
||||
ActionPutBucketLifecycleConfiguration = "s3_PutBucketLifecycleConfiguration"
|
||||
ActionGetBucketLifecycleConfiguration = "s3_GetBucketLifecycleConfiguration"
|
||||
ActionDeleteBucketLifecycle = "s3_DeleteBucketLifecycle"
|
||||
ActionPutBucketLogging = "s3_PutBucketLogging"
|
||||
ActionGetBucketLogging = "s3_GetBucketLogging"
|
||||
ActionPutBucketRequestPayment = "s3_PutBucketRequestPayment"
|
||||
ActionGetBucketRequestPayment = "s3_GetBucketRequestPayment"
|
||||
ActionPutBucketMetricsConfiguration = "s3_PutBucketMetricsConfiguration"
|
||||
ActionGetBucketMetricsConfiguration = "s3_GetBucketMetricsConfiguration"
|
||||
ActionListBucketMetricsConfigurations = "s3_ListBucketMetricsConfigurations"
|
||||
ActionDeleteBucketMetricsConfiguration = "s3_DeleteBucketMetricsConfiguration"
|
||||
ActionPutBucketReplication = "s3_PutBucketReplication"
|
||||
ActionGetBucketReplication = "s3_GetBucketReplication"
|
||||
ActionDeleteBucketReplication = "s3_DeleteBucketReplication"
|
||||
ActionPutPublicAccessBlock = "s3_PutPublicAccessBlock"
|
||||
ActionGetPublicAccessBlock = "s3_GetPublicAccessBlock"
|
||||
ActionDeletePublicAccessBlock = "s3_DeletePublicAccessBlock"
|
||||
ActionPutBucketNotificationConfiguration = "s3_PutBucketNotificationConfiguration"
|
||||
ActionGetBucketNotificationConfiguration = "s3_GetBucketNotificationConfiguration"
|
||||
ActionPutBucketAccelerateConfiguration = "s3_PutBucketAccelerateConfiguration"
|
||||
ActionGetBucketAccelerateConfiguration = "s3_GetBucketAccelerateConfiguration"
|
||||
ActionPutBucketWebsite = "s3_PutBucketWebsite"
|
||||
ActionGetBucketWebsite = "s3_GetBucketWebsite"
|
||||
ActionDeleteBucketWebsite = "s3_DeleteBucketWebsite"
|
||||
ActionGetBucketPolicyStatus = "s3_GetBucketPolicyStatus"
|
||||
ActionGetBucketLocation = "s3_GetBucketLocation"
|
||||
|
||||
// Admin actions
|
||||
ActionAdminCreateUser = "admin_CreateUser"
|
||||
@@ -281,4 +323,184 @@ func init() {
|
||||
Name: "DeleteBucketCors",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketOwnershipControls] = Action{
|
||||
Name: "PutBucketOwnershipControls",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketOwnershipControls] = Action{
|
||||
Name: "GetBucketOwnershipControls",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketOwnershipControls] = Action{
|
||||
Name: "DeleteBucketOwnershipControls",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionOptions] = Action{
|
||||
Name: "Options",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketAnalyticsConfiguration] = Action{
|
||||
Name: "PutBucketAnalyticsConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketAnalyticsConfiguration] = Action{
|
||||
Name: "GetBucketAnalyticsConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionListBucketAnalyticsConfigurations] = Action{
|
||||
Name: "ListBucketAnalyticsConfigurations",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketAnalyticsConfiguration] = Action{
|
||||
Name: "DeleteBucketAnalyticsConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketEncryption] = Action{
|
||||
Name: "PutBucketEncryption",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketEncryption] = Action{
|
||||
Name: "GetBucketEncryption",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketEncryption] = Action{
|
||||
Name: "DeleteBucketEncryption",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketIntelligentTieringConfiguration] = Action{
|
||||
Name: "PutBucketIntelligentTieringConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketIntelligentTieringConfiguration] = Action{
|
||||
Name: "GetBucketIntelligentTieringConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionListBucketIntelligentTieringConfigurations] = Action{
|
||||
Name: "ListBucketIntelligentTieringConfigurations",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketIntelligentTieringConfiguration] = Action{
|
||||
Name: "DeleteBucketIntelligentTieringConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketInventoryConfiguration] = Action{
|
||||
Name: "PutBucketInventoryConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketInventoryConfiguration] = Action{
|
||||
Name: "GetBucketInventoryConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionListBucketInventoryConfigurations] = Action{
|
||||
Name: "ListBucketInventoryConfigurations",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketInventoryConfiguration] = Action{
|
||||
Name: "DeleteBucketInventoryConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketLifecycleConfiguration] = Action{
|
||||
Name: "PutBucketLifecycleConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketLifecycleConfiguration] = Action{
|
||||
Name: "GetBucketLifecycleConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketLifecycle] = Action{
|
||||
Name: "DeleteBucketLifecycle",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketLogging] = Action{
|
||||
Name: "PutBucketLogging",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketLogging] = Action{
|
||||
Name: "GetBucketLogging",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketRequestPayment] = Action{
|
||||
Name: "PutBucketRequestPayment",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketRequestPayment] = Action{
|
||||
Name: "GetBucketRequestPayment",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketMetricsConfiguration] = Action{
|
||||
Name: "PutBucketMetricsConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketMetricsConfiguration] = Action{
|
||||
Name: "GetBucketMetricsConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionListBucketMetricsConfigurations] = Action{
|
||||
Name: "ListBucketMetricsConfigurations",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketMetricsConfiguration] = Action{
|
||||
Name: "DeleteBucketMetricsConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketReplication] = Action{
|
||||
Name: "PutBucketReplication",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketReplication] = Action{
|
||||
Name: "GetBucketReplication",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketReplication] = Action{
|
||||
Name: "DeleteBucketReplication",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutPublicAccessBlock] = Action{
|
||||
Name: "PutPublicAccessBlock",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetPublicAccessBlock] = Action{
|
||||
Name: "GetPublicAccessBlock",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeletePublicAccessBlock] = Action{
|
||||
Name: "DeletePublicAccessBlock",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketNotificationConfiguration] = Action{
|
||||
Name: "PutBucketNotificationConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketNotificationConfiguration] = Action{
|
||||
Name: "GetBucketNotificationConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketAccelerateConfiguration] = Action{
|
||||
Name: "PutBucketAccelerateConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketAccelerateConfiguration] = Action{
|
||||
Name: "GetBucketAccelerateConfiguration",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionPutBucketWebsite] = Action{
|
||||
Name: "PutBucketWebsite",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketWebsite] = Action{
|
||||
Name: "GetBucketWebsite",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionDeleteBucketWebsite] = Action{
|
||||
Name: "DeleteBucketWebsite",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketPolicyStatus] = Action{
|
||||
Name: "GetBucketPolicyStatus",
|
||||
Service: "s3",
|
||||
}
|
||||
ActionMap[ActionGetBucketLocation] = Action{
|
||||
Name: "GetBucketLocation",
|
||||
Service: "s3",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,14 @@ type Tag struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
// Manager is a manager of metrics plugins
|
||||
type Manager struct {
|
||||
// Manager is the interface definition for metrics manager
|
||||
type Manager interface {
|
||||
Send(ctx *fiber.Ctx, err error, action string, count int64, status int)
|
||||
Close()
|
||||
}
|
||||
|
||||
// manager is a manager of metrics plugins
|
||||
type manager struct {
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
|
||||
@@ -59,7 +65,7 @@ type Config struct {
|
||||
}
|
||||
|
||||
// NewManager initializes metrics plugins and returns a new metrics manager
|
||||
func NewManager(ctx context.Context, conf Config) (*Manager, error) {
|
||||
func NewManager(ctx context.Context, conf Config) (Manager, error) {
|
||||
if len(conf.StatsdServers) == 0 && len(conf.DogStatsdServers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -74,7 +80,7 @@ func NewManager(ctx context.Context, conf Config) (*Manager, error) {
|
||||
|
||||
addDataChan := make(chan datapoint, dataItemCount)
|
||||
|
||||
mgr := &Manager{
|
||||
mgr := &manager{
|
||||
addDataChan: addDataChan,
|
||||
ctx: ctx,
|
||||
config: conf,
|
||||
@@ -112,7 +118,7 @@ func NewManager(ctx context.Context, conf Config) (*Manager, error) {
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Send(ctx *fiber.Ctx, err error, action string, count int64, status int) {
|
||||
func (m *manager) Send(ctx *fiber.Ctx, err error, action string, count int64, status int) {
|
||||
// In case of Authentication failures, url parsing ...
|
||||
if action == "" {
|
||||
action = ActionUndetected
|
||||
@@ -168,12 +174,12 @@ func (m *Manager) Send(ctx *fiber.Ctx, err error, action string, count int64, st
|
||||
}
|
||||
|
||||
// increment increments the key by one
|
||||
func (m *Manager) increment(key string, tags ...Tag) {
|
||||
func (m *manager) increment(key string, tags ...Tag) {
|
||||
m.add(key, 1, tags...)
|
||||
}
|
||||
|
||||
// add adds value to key
|
||||
func (m *Manager) add(key string, value int64, tags ...Tag) {
|
||||
func (m *manager) add(key string, value int64, tags ...Tag) {
|
||||
if m.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -192,7 +198,7 @@ func (m *Manager) add(key string, value int64, tags ...Tag) {
|
||||
}
|
||||
|
||||
// Close closes metrics channels, waits for data to complete, closes all plugins
|
||||
func (m *Manager) Close() {
|
||||
func (m *manager) Close() {
|
||||
// drain the datapoint channels
|
||||
close(m.addDataChan)
|
||||
m.wg.Wait()
|
||||
@@ -209,7 +215,7 @@ type publisher interface {
|
||||
Close()
|
||||
}
|
||||
|
||||
func (m *Manager) addForwarder(addChan <-chan datapoint) {
|
||||
func (m *manager) addForwarder(addChan <-chan datapoint) {
|
||||
for data := range addChan {
|
||||
for _, s := range m.publishers {
|
||||
s.Add(data.key, data.value, data.tags...)
|
||||
|
||||
35
plugins/plugins.go
Normal file
35
plugins/plugins.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright 2025 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package plugins
|
||||
|
||||
import "github.com/versity/versitygw/backend"
|
||||
|
||||
// BackendPlugin defines an interface for creating backend
|
||||
// implementation instances.
|
||||
// Plugins implementing this interface can be built as shared
|
||||
// libraries using Go's plugin system (to build use `go build -buildmode=plugin`).
|
||||
// The shared library should export an instance of
|
||||
// this interface in a variable named `Backend`.
|
||||
type BackendPlugin interface {
|
||||
// New creates and initializes a new backend.Backend instance.
|
||||
// The config parameter specifies the path of the file containing
|
||||
// the configuration for the backend.
|
||||
//
|
||||
// Implementations of this method should perform the necessary steps to
|
||||
// establish a connection to the underlying storage system or service
|
||||
// (e.g., network storage system, distributed storage system, cloud storage)
|
||||
// and configure it according to the provided configuration.
|
||||
New(config string) (backend.Backend, error)
|
||||
}
|
||||
@@ -18,30 +18,59 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/metrics"
|
||||
"github.com/versity/versitygw/s3api/controllers"
|
||||
"github.com/versity/versitygw/s3api/middlewares"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
)
|
||||
|
||||
type S3AdminRouter struct{}
|
||||
|
||||
func (ar *S3AdminRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger) {
|
||||
controller := controllers.NewAdminController(iam, be, logger)
|
||||
func (ar *S3AdminRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, root middlewares.RootUserConfig, region string, debug bool) {
|
||||
ctrl := controllers.NewAdminController(iam, be, logger)
|
||||
services := &controllers.Services{
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
// CreateUser admin api
|
||||
app.Patch("/create-user", controller.CreateUser)
|
||||
app.Patch("/create-user",
|
||||
controllers.ProcessHandlers(ctrl.CreateUser, metrics.ActionAdminCreateUser, services,
|
||||
middlewares.VerifyV4Signature(root, iam, region),
|
||||
middlewares.IsAdmin(metrics.ActionAdminCreateUser),
|
||||
))
|
||||
|
||||
// DeleteUsers admin api
|
||||
app.Patch("/delete-user", controller.DeleteUser)
|
||||
app.Patch("/delete-user",
|
||||
controllers.ProcessHandlers(ctrl.DeleteUser, metrics.ActionAdminDeleteUser, services,
|
||||
middlewares.VerifyV4Signature(root, iam, region),
|
||||
middlewares.IsAdmin(metrics.ActionAdminDeleteUser),
|
||||
))
|
||||
|
||||
// UpdateUser admin api
|
||||
app.Patch("/update-user", controller.UpdateUser)
|
||||
app.Patch("/update-user",
|
||||
controllers.ProcessHandlers(ctrl.UpdateUser, metrics.ActionAdminUpdateUser, services,
|
||||
middlewares.VerifyV4Signature(root, iam, region),
|
||||
middlewares.IsAdmin(metrics.ActionAdminUpdateUser),
|
||||
))
|
||||
|
||||
// ListUsers admin api
|
||||
app.Patch("/list-users", controller.ListUsers)
|
||||
app.Patch("/list-users",
|
||||
controllers.ProcessHandlers(ctrl.ListUsers, metrics.ActionAdminListUsers, services,
|
||||
middlewares.VerifyV4Signature(root, iam, region),
|
||||
middlewares.IsAdmin(metrics.ActionAdminListUsers),
|
||||
))
|
||||
|
||||
// ChangeBucketOwner admin api
|
||||
app.Patch("/change-bucket-owner", controller.ChangeBucketOwner)
|
||||
app.Patch("/change-bucket-owner",
|
||||
controllers.ProcessHandlers(ctrl.ChangeBucketOwner, metrics.ActionAdminChangeBucketOwner, services,
|
||||
middlewares.VerifyV4Signature(root, iam, region),
|
||||
middlewares.IsAdmin(metrics.ActionAdminChangeBucketOwner),
|
||||
))
|
||||
|
||||
// ListBucketsAndOwners admin api
|
||||
app.Patch("/list-buckets", controller.ListBuckets)
|
||||
app.Patch("/list-buckets",
|
||||
controllers.ProcessHandlers(ctrl.ListBuckets, metrics.ActionAdminListBuckets, services,
|
||||
middlewares.VerifyV4Signature(root, iam, region),
|
||||
middlewares.IsAdmin(metrics.ActionAdminListBuckets),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/s3api/controllers"
|
||||
"github.com/versity/versitygw/s3api/middlewares"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
)
|
||||
@@ -31,6 +32,8 @@ type S3AdminServer struct {
|
||||
router *S3AdminRouter
|
||||
port string
|
||||
cert *tls.Certificate
|
||||
quiet bool
|
||||
debug bool
|
||||
}
|
||||
|
||||
func NewAdminServer(app *fiber.App, be backend.Backend, root middlewares.RootUserConfig, port, region string, iam auth.IAMService, l s3log.AuditLogger, opts ...AdminOpt) *S3AdminServer {
|
||||
@@ -46,17 +49,15 @@ func NewAdminServer(app *fiber.App, be backend.Backend, root middlewares.RootUse
|
||||
}
|
||||
|
||||
// Logging middlewares
|
||||
app.Use(logger.New())
|
||||
app.Use(middlewares.DecodeURL(l, nil))
|
||||
if !server.quiet {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
|
||||
}))
|
||||
}
|
||||
app.Use(controllers.WrapMiddleware(middlewares.DecodeURL, l, nil))
|
||||
app.Use(middlewares.DebugLogger())
|
||||
|
||||
// Authentication middlewares
|
||||
app.Use(middlewares.VerifyV4Signature(root, iam, l, nil, region, false))
|
||||
app.Use(middlewares.VerifyMD5Body(l))
|
||||
|
||||
// Admin role checker
|
||||
app.Use(middlewares.IsAdmin(l))
|
||||
|
||||
server.router.Init(app, be, iam, l)
|
||||
server.router.Init(app, be, iam, l, root, region, server.debug)
|
||||
|
||||
return server
|
||||
}
|
||||
@@ -67,6 +68,16 @@ func WithAdminSrvTLS(cert tls.Certificate) AdminOpt {
|
||||
return func(s *S3AdminServer) { s.cert = &cert }
|
||||
}
|
||||
|
||||
// WithQuiet silences default logging output
|
||||
func WithAdminQuiet() AdminOpt {
|
||||
return func(s *S3AdminServer) { s.quiet = true }
|
||||
}
|
||||
|
||||
// WithAdminDebug enables the debug logging
|
||||
func WithAdminDebug() AdminOpt {
|
||||
return func(s *S3AdminServer) { s.debug = true }
|
||||
}
|
||||
|
||||
func (sa *S3AdminServer) Serve() (err error) {
|
||||
if sa.cert != nil {
|
||||
return sa.app.ListenTLSWithCertificate(sa.port, *sa.cert)
|
||||
|
||||
@@ -15,17 +15,13 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/metrics"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
@@ -41,23 +37,19 @@ func NewAdminController(iam auth.IAMService, be backend.Backend, l s3log.AuditLo
|
||||
return AdminController{iam: iam, be: be, l: l}
|
||||
}
|
||||
|
||||
func (c AdminController) CreateUser(ctx *fiber.Ctx) error {
|
||||
func (c AdminController) CreateUser(ctx *fiber.Ctx) (*Response, error) {
|
||||
var usr auth.Account
|
||||
err := xml.Unmarshal(ctx.Body(), &usr)
|
||||
if err != nil {
|
||||
return SendResponse(ctx, s3err.GetAPIError(s3err.ErrMalformedXML),
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminCreateUser,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
if !usr.Role.IsValid() {
|
||||
return SendResponse(ctx, s3err.GetAPIError(s3err.ErrAdminInvalidUserRole),
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminCreateUser,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrAdminInvalidUserRole)
|
||||
}
|
||||
|
||||
err = c.iam.CreateAccount(usr)
|
||||
@@ -66,138 +58,106 @@ func (c AdminController) CreateUser(ctx *fiber.Ctx) error {
|
||||
err = s3err.GetAPIError(s3err.ErrAdminUserExists)
|
||||
}
|
||||
|
||||
return SendResponse(ctx, err,
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminCreateUser,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, err
|
||||
}
|
||||
|
||||
return SendResponse(ctx, nil,
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminCreateUser,
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
Status: http.StatusCreated,
|
||||
})
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c AdminController) UpdateUser(ctx *fiber.Ctx) error {
|
||||
func (c AdminController) UpdateUser(ctx *fiber.Ctx) (*Response, error) {
|
||||
access := ctx.Query("access")
|
||||
if access == "" {
|
||||
return SendResponse(ctx, s3err.GetAPIError(s3err.ErrAdminMissingUserAcess),
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminUpdateUser,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrAdminMissingUserAcess)
|
||||
}
|
||||
|
||||
var props auth.MutableProps
|
||||
if err := xml.Unmarshal(ctx.Body(), &props); err != nil {
|
||||
return SendResponse(ctx, s3err.GetAPIError(s3err.ErrMalformedXML),
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminUpdateUser,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
err := c.iam.UpdateUserAccount(access, props)
|
||||
err := props.Validate()
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrAdminInvalidUserRole)
|
||||
}
|
||||
|
||||
err = c.iam.UpdateUserAccount(access, props)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "user not found") {
|
||||
err = s3err.GetAPIError(s3err.ErrAdminUserNotFound)
|
||||
}
|
||||
|
||||
return SendResponse(ctx, err,
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminUpdateUser,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, err
|
||||
}
|
||||
|
||||
return SendResponse(ctx, nil,
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminUpdateUser,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c AdminController) DeleteUser(ctx *fiber.Ctx) error {
|
||||
func (c AdminController) DeleteUser(ctx *fiber.Ctx) (*Response, error) {
|
||||
access := ctx.Query("access")
|
||||
if access == "" {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrAdminMissingUserAcess)
|
||||
}
|
||||
|
||||
err := c.iam.DeleteUserAccount(access)
|
||||
return SendResponse(ctx, err,
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminDeleteUser,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c AdminController) ListUsers(ctx *fiber.Ctx) error {
|
||||
func (c AdminController) ListUsers(ctx *fiber.Ctx) (*Response, error) {
|
||||
accs, err := c.iam.ListUserAccounts()
|
||||
return SendXMLResponse(ctx,
|
||||
auth.ListUserAccountsResult{
|
||||
Accounts: accs,
|
||||
}, err,
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminListUsers,
|
||||
})
|
||||
return &Response{
|
||||
Data: auth.ListUserAccountsResult{Accounts: accs},
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c AdminController) ChangeBucketOwner(ctx *fiber.Ctx) error {
|
||||
func (c AdminController) ChangeBucketOwner(ctx *fiber.Ctx) (*Response, error) {
|
||||
owner := ctx.Query("owner")
|
||||
bucket := ctx.Query("bucket")
|
||||
|
||||
accs, err := auth.CheckIfAccountsExist([]string{owner}, c.iam)
|
||||
if err != nil {
|
||||
return SendResponse(ctx, err,
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminChangeBucketOwner,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, err
|
||||
}
|
||||
if len(accs) > 0 {
|
||||
return SendResponse(ctx, s3err.GetAPIError(s3err.ErrAdminUserNotFound),
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminChangeBucketOwner,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrAdminUserNotFound)
|
||||
}
|
||||
|
||||
acl := auth.ACL{
|
||||
Owner: owner,
|
||||
Grantees: []auth.Grantee{
|
||||
{
|
||||
Permission: auth.PermissionFullControl,
|
||||
Access: owner,
|
||||
Type: types.TypeCanonicalUser,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
aclParsed, err := json.Marshal(acl)
|
||||
if err != nil {
|
||||
return SendResponse(ctx, fmt.Errorf("failed to marshal the bucket acl: %w", err),
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminChangeBucketOwner,
|
||||
})
|
||||
}
|
||||
|
||||
err = c.be.ChangeBucketOwner(ctx.Context(), bucket, aclParsed)
|
||||
return SendResponse(ctx, err,
|
||||
&MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminChangeBucketOwner,
|
||||
})
|
||||
err = c.be.ChangeBucketOwner(ctx.Context(), bucket, owner)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c AdminController) ListBuckets(ctx *fiber.Ctx) error {
|
||||
func (c AdminController) ListBuckets(ctx *fiber.Ctx) (*Response, error) {
|
||||
buckets, err := c.be.ListBucketsAndOwners(ctx.Context())
|
||||
return SendXMLResponse(ctx,
|
||||
s3response.ListBucketsResult{
|
||||
return &Response{
|
||||
Data: s3response.ListBucketsResult{
|
||||
Buckets: buckets,
|
||||
}, err, &MetaOpts{
|
||||
Logger: c.l,
|
||||
Action: metrics.ActionAdminListBuckets,
|
||||
})
|
||||
},
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, err
|
||||
}
|
||||
|
||||
@@ -16,439 +16,564 @@ package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func TestAdminController_CreateUser(t *testing.T) {
|
||||
func TestNewAdminController(t *testing.T) {
|
||||
type args struct {
|
||||
req *http.Request
|
||||
iam auth.IAMService
|
||||
be backend.Backend
|
||||
l s3log.AuditLogger
|
||||
}
|
||||
|
||||
adminController := AdminController{
|
||||
iam: &IAMServiceMock{
|
||||
CreateAccountFunc: func(account auth.Account) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
app.Patch("/create-user", adminController.CreateUser)
|
||||
|
||||
succUser := `
|
||||
<Account>
|
||||
<Access>access</Access>
|
||||
<Secret>secret</Secret>
|
||||
<Role>admin</Role>
|
||||
<UserID>0</UserID>
|
||||
<GroupID>0</GroupID>
|
||||
</Account>
|
||||
`
|
||||
invuser := `
|
||||
<Account>
|
||||
<Access>access</Access>
|
||||
<Secret>secret</Secret>
|
||||
<Role>invalid_role</Role>
|
||||
<UserID>0</UserID>
|
||||
<GroupID>0</GroupID>
|
||||
</Account>
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
app *fiber.App
|
||||
args args
|
||||
wantErr bool
|
||||
statusCode int
|
||||
name string
|
||||
args args
|
||||
want AdminController
|
||||
}{
|
||||
{
|
||||
name: "Admin-create-user-malformed-body",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/create-user", nil),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 400,
|
||||
},
|
||||
{
|
||||
name: "Admin-create-user-invalid-requester-role",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/create-user", strings.NewReader(invuser)),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 400,
|
||||
},
|
||||
{
|
||||
name: "Admin-create-user-success",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/create-user", strings.NewReader(succUser)),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 201,
|
||||
name: "initialize admin api",
|
||||
args: args{},
|
||||
want: AdminController{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp, err := tt.app.Test(tt.args.req)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := NewAdminController(tt.args.iam, tt.args.be, tt.args.l)
|
||||
assert.Equal(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AdminController.CreateUser() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
func TestAdminController_CreateUser(t *testing.T) {
|
||||
validBody, err := xml.Marshal(auth.Account{
|
||||
Access: "access",
|
||||
Secret: "secret",
|
||||
Role: auth.RoleAdmin,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
if resp.StatusCode != tt.statusCode {
|
||||
t.Errorf("AdminController.CreateUser() statusCode = %v, wantStatusCode = %v", resp.StatusCode, tt.statusCode)
|
||||
}
|
||||
invalidUserRoleBody, err := xml.Marshal(auth.Account{
|
||||
Access: "access",
|
||||
Secret: "secret",
|
||||
Role: auth.Role("invalid_role"),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "invalid request body",
|
||||
input: testInput{
|
||||
body: []byte("invalid_request_body"),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid user role",
|
||||
input: testInput{
|
||||
body: invalidUserRoleBody,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminInvalidUserRole),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns user exists error",
|
||||
input: testInput{
|
||||
body: validBody,
|
||||
beErr: auth.ErrUserExists,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminUserExists),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns other error",
|
||||
input: testInput{
|
||||
body: validBody,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
body: validBody,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
Status: http.StatusCreated,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
iam := &IAMServiceMock{
|
||||
CreateAccountFunc: func(account auth.Account) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := AdminController{
|
||||
iam: iam,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.CreateUser,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
body: tt.input.body,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminController_UpdateUser(t *testing.T) {
|
||||
type args struct {
|
||||
req *http.Request
|
||||
}
|
||||
validBody, err := xml.Marshal(auth.MutableProps{
|
||||
Secret: utils.GetStringPtr("secret"),
|
||||
Role: auth.RoleAdmin,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
adminController := AdminController{
|
||||
iam: &IAMServiceMock{
|
||||
UpdateUserAccountFunc: func(access string, props auth.MutableProps) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
app.Patch("/update-user", adminController.UpdateUser)
|
||||
|
||||
adminControllerErr := AdminController{
|
||||
iam: &IAMServiceMock{
|
||||
UpdateUserAccountFunc: func(access string, props auth.MutableProps) error {
|
||||
return auth.ErrNoSuchUser
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
appNotFound := fiber.New()
|
||||
|
||||
appNotFound.Patch("/update-user", adminControllerErr.UpdateUser)
|
||||
|
||||
succUser := `
|
||||
<Account>
|
||||
<Secret>secret</Secret>
|
||||
<UserID>0</UserID>
|
||||
<GroupID>0</GroupID>
|
||||
</Account>
|
||||
`
|
||||
invalidUserRoleBody, err := xml.Marshal(auth.MutableProps{
|
||||
Secret: utils.GetStringPtr("secret"),
|
||||
Role: auth.Role("invalid_role"),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
app *fiber.App
|
||||
args args
|
||||
wantErr bool
|
||||
statusCode int
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "Admin-update-user-success",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/update-user?access=access", strings.NewReader(succUser)),
|
||||
name: "missing user access key",
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminMissingUserAcess),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "Admin-update-user-missing-access",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/update-user", strings.NewReader(succUser)),
|
||||
name: "invalid request body",
|
||||
input: testInput{
|
||||
body: []byte("invalid_request_body"),
|
||||
queries: map[string]string{
|
||||
"access": "user",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "Admin-update-user-invalid-request-body",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/update-user?access=access", nil),
|
||||
name: "invalid user role",
|
||||
input: testInput{
|
||||
body: invalidUserRoleBody,
|
||||
queries: map[string]string{
|
||||
"access": "user",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminInvalidUserRole),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 400,
|
||||
},
|
||||
{
|
||||
name: "Admin-update-user-not-found",
|
||||
app: appNotFound,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/update-user?access=access", strings.NewReader(succUser)),
|
||||
name: "backend returns user not found error",
|
||||
input: testInput{
|
||||
body: validBody,
|
||||
beErr: auth.ErrNoSuchUser,
|
||||
queries: map[string]string{
|
||||
"access": "user",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminUserNotFound),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns other error",
|
||||
input: testInput{
|
||||
body: validBody,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
queries: map[string]string{
|
||||
"access": "user",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
body: validBody,
|
||||
queries: map[string]string{
|
||||
"access": "user",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 404,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp, err := tt.app.Test(tt.args.req)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
iam := &IAMServiceMock{
|
||||
UpdateUserAccountFunc: func(access string, props auth.MutableProps) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
}
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AdminController.UpdateUser() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
ctrl := AdminController{
|
||||
iam: iam,
|
||||
}
|
||||
|
||||
if resp.StatusCode != tt.statusCode {
|
||||
t.Errorf("AdminController.UpdateUser() statusCode = %v, wantStatusCode = %v", resp.StatusCode, tt.statusCode)
|
||||
}
|
||||
testController(
|
||||
t,
|
||||
ctrl.UpdateUser,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
body: tt.input.body,
|
||||
queries: tt.input.queries,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminController_DeleteUser(t *testing.T) {
|
||||
type args struct {
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
adminController := AdminController{
|
||||
iam: &IAMServiceMock{
|
||||
DeleteUserAccountFunc: func(access string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
app.Patch("/delete-user", adminController.DeleteUser)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
app *fiber.App
|
||||
args args
|
||||
wantErr bool
|
||||
statusCode int
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "Admin-delete-user-success",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/delete-user?access=test", nil),
|
||||
name: "missing user access key",
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminMissingUserAcess),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns other error",
|
||||
input: testInput{
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
queries: map[string]string{
|
||||
"access": "user",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
queries: map[string]string{
|
||||
"access": "user",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 200,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp, err := tt.app.Test(tt.args.req)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
iam := &IAMServiceMock{
|
||||
DeleteUserAccountFunc: func(access string) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
}
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AdminController.DeleteUser() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
ctrl := AdminController{
|
||||
iam: iam,
|
||||
}
|
||||
|
||||
if resp.StatusCode != tt.statusCode {
|
||||
t.Errorf("AdminController.DeleteUser() statusCode = %v, wantStatusCode = %v", resp.StatusCode, tt.statusCode)
|
||||
}
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteUser,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
queries: tt.input.queries,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminController_ListUsers(t *testing.T) {
|
||||
type args struct {
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
adminController := AdminController{
|
||||
iam: &IAMServiceMock{
|
||||
ListUserAccountsFunc: func() ([]auth.Account, error) {
|
||||
return []auth.Account{}, nil
|
||||
},
|
||||
accs := []auth.Account{
|
||||
{
|
||||
Access: "access",
|
||||
Secret: "secret",
|
||||
},
|
||||
{
|
||||
Access: "access",
|
||||
Secret: "secret",
|
||||
},
|
||||
}
|
||||
|
||||
adminControllerErr := AdminController{
|
||||
iam: &IAMServiceMock{
|
||||
ListUserAccountsFunc: func() ([]auth.Account, error) {
|
||||
return []auth.Account{}, fmt.Errorf("server error")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
appErr := fiber.New()
|
||||
appErr.Patch("/list-users", adminControllerErr.ListUsers)
|
||||
|
||||
appSucc := fiber.New()
|
||||
appSucc.Patch("/list-users", adminController.ListUsers)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
app *fiber.App
|
||||
args args
|
||||
wantErr bool
|
||||
statusCode int
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "Admin-list-users-iam-error",
|
||||
app: appErr,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/list-users", nil),
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
beRes: []auth.Account{},
|
||||
beErr: s3err.GetAPIError(s3err.ErrInternalError),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: auth.ListUserAccountsResult{
|
||||
Accounts: []auth.Account{},
|
||||
},
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInternalError),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 500,
|
||||
},
|
||||
{
|
||||
name: "Admin-list-users-success",
|
||||
app: appSucc,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/list-users", nil),
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
beRes: accs,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: auth.ListUserAccountsResult{
|
||||
Accounts: accs,
|
||||
},
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 200,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp, err := tt.app.Test(tt.args.req)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
iam := &IAMServiceMock{
|
||||
ListUserAccountsFunc: func() ([]auth.Account, error) {
|
||||
return tt.input.beRes.([]auth.Account), tt.input.beErr
|
||||
},
|
||||
}
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AdminController.ListUsers() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
ctrl := AdminController{
|
||||
iam: iam,
|
||||
}
|
||||
|
||||
if resp.StatusCode != tt.statusCode {
|
||||
t.Errorf("AdminController.ListUsers() statusCode = %v, wantStatusCode = %v", resp.StatusCode, tt.statusCode)
|
||||
}
|
||||
testController(
|
||||
t,
|
||||
ctrl.ListUsers,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
queries: tt.input.queries,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminController_ChangeBucketOwner(t *testing.T) {
|
||||
type args struct {
|
||||
req *http.Request
|
||||
}
|
||||
adminController := AdminController{
|
||||
be: &BackendMock{
|
||||
ChangeBucketOwnerFunc: func(contextMoqParam context.Context, bucket string, acl []byte) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
iam: &IAMServiceMock{
|
||||
GetUserAccountFunc: func(access string) (auth.Account, error) {
|
||||
return auth.Account{}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
adminControllerIamErr := AdminController{
|
||||
iam: &IAMServiceMock{
|
||||
GetUserAccountFunc: func(access string) (auth.Account, error) {
|
||||
return auth.Account{}, fmt.Errorf("unknown server error")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
adminControllerIamAccDoesNotExist := AdminController{
|
||||
iam: &IAMServiceMock{
|
||||
GetUserAccountFunc: func(access string) (auth.Account, error) {
|
||||
return auth.Account{}, auth.ErrNoSuchUser
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Patch("/change-bucket-owner", adminController.ChangeBucketOwner)
|
||||
|
||||
appIamErr := fiber.New()
|
||||
appIamErr.Patch("/change-bucket-owner", adminControllerIamErr.ChangeBucketOwner)
|
||||
|
||||
appIamNoSuchUser := fiber.New()
|
||||
appIamNoSuchUser.Patch("/change-bucket-owner", adminControllerIamAccDoesNotExist.ChangeBucketOwner)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
app *fiber.App
|
||||
args args
|
||||
wantErr bool
|
||||
statusCode int
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "Change-bucket-owner-check-account-server-error",
|
||||
app: appIamErr,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/change-bucket-owner", nil),
|
||||
name: "fails to get user account",
|
||||
input: testInput{
|
||||
extraMockErr: s3err.GetAPIError(s3err.ErrInternalError),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: errors.New("check user account: "),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 500,
|
||||
},
|
||||
{
|
||||
name: "Change-bucket-owner-acc-does-not-exist",
|
||||
app: appIamNoSuchUser,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/change-bucket-owner", nil),
|
||||
name: "user not found",
|
||||
input: testInput{
|
||||
extraMockErr: auth.ErrNoSuchUser,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminUserNotFound),
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "Change-bucket-owner-success",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/change-bucket-owner?bucket=bucket&owner=owner", nil),
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
beErr: s3err.GetAPIError(s3err.ErrAdminMethodNotSupported),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminMethodNotSupported),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 200,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp, err := tt.app.Test(tt.args.req)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
iam := &IAMServiceMock{
|
||||
GetUserAccountFunc: func(access string) (auth.Account, error) {
|
||||
return auth.Account{}, tt.input.extraMockErr
|
||||
},
|
||||
}
|
||||
be := &BackendMock{
|
||||
ChangeBucketOwnerFunc: func(contextMoqParam context.Context, bucket, owner string) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
}
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AdminController.ChangeBucketOwner() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
ctrl := AdminController{
|
||||
iam: iam,
|
||||
be: be,
|
||||
}
|
||||
|
||||
if resp.StatusCode != tt.statusCode {
|
||||
t.Errorf("AdminController.ChangeBucketOwner() statusCode = %v, wantStatusCode = %v", resp.StatusCode, tt.statusCode)
|
||||
}
|
||||
testController(
|
||||
t,
|
||||
ctrl.ChangeBucketOwner,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminController_ListBuckets(t *testing.T) {
|
||||
type args struct {
|
||||
req *http.Request
|
||||
}
|
||||
adminController := AdminController{
|
||||
be: &BackendMock{
|
||||
ListBucketsAndOwnersFunc: func(contextMoqParam context.Context) ([]s3response.Bucket, error) {
|
||||
return []s3response.Bucket{}, nil
|
||||
},
|
||||
res := []s3response.Bucket{
|
||||
{
|
||||
Name: "bucket",
|
||||
Owner: "owner",
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Patch("/list-buckets", adminController.ListBuckets)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
app *fiber.App
|
||||
args args
|
||||
wantErr bool
|
||||
statusCode int
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "List-buckets-success",
|
||||
app: app,
|
||||
args: args{
|
||||
req: httptest.NewRequest(http.MethodPatch, "/list-buckets", nil),
|
||||
name: "backend returns other error",
|
||||
input: testInput{
|
||||
beRes: []s3response.Bucket{},
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.ListBucketsResult{
|
||||
Buckets: []s3response.Bucket{},
|
||||
},
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
beRes: res,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.ListBucketsResult{
|
||||
Buckets: res,
|
||||
},
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
statusCode: 200,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp, err := tt.app.Test(tt.args.req)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
ListBucketsAndOwnersFunc: func(contextMoqParam context.Context) ([]s3response.Bucket, error) {
|
||||
return tt.input.beRes.([]s3response.Bucket), tt.input.beErr
|
||||
},
|
||||
}
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AdminController.ListBuckets() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
ctrl := AdminController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
if resp.StatusCode != tt.statusCode {
|
||||
t.Errorf("AdminController.ListBuckets() statusCode = %v, wantStatusCode = %v", resp.StatusCode, tt.statusCode)
|
||||
}
|
||||
testController(
|
||||
t,
|
||||
ctrl.ListBuckets,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,13 @@ var _ backend.Backend = &BackendMock{}
|
||||
// AbortMultipartUploadFunc: func(contextMoqParam context.Context, abortMultipartUploadInput *s3.AbortMultipartUploadInput) error {
|
||||
// panic("mock out the AbortMultipartUpload method")
|
||||
// },
|
||||
// ChangeBucketOwnerFunc: func(contextMoqParam context.Context, bucket string, acl []byte) error {
|
||||
// ChangeBucketOwnerFunc: func(contextMoqParam context.Context, bucket string, owner string) error {
|
||||
// panic("mock out the ChangeBucketOwner method")
|
||||
// },
|
||||
// CompleteMultipartUploadFunc: func(contextMoqParam context.Context, completeMultipartUploadInput *s3.CompleteMultipartUploadInput) (*s3.CompleteMultipartUploadOutput, error) {
|
||||
// CompleteMultipartUploadFunc: func(contextMoqParam context.Context, completeMultipartUploadInput *s3.CompleteMultipartUploadInput) (s3response.CompleteMultipartUploadResult, string, error) {
|
||||
// panic("mock out the CompleteMultipartUpload method")
|
||||
// },
|
||||
// CopyObjectFunc: func(contextMoqParam context.Context, copyObjectInput s3response.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||
// CopyObjectFunc: func(contextMoqParam context.Context, copyObjectInput s3response.CopyObjectInput) (s3response.CopyObjectOutput, error) {
|
||||
// panic("mock out the CopyObject method")
|
||||
// },
|
||||
// CreateBucketFunc: func(contextMoqParam context.Context, createBucketInput *s3.CreateBucketInput, defaultACL []byte) error {
|
||||
@@ -134,7 +134,7 @@ var _ backend.Backend = &BackendMock{}
|
||||
// PutBucketAclFunc: func(contextMoqParam context.Context, bucket string, data []byte) error {
|
||||
// panic("mock out the PutBucketAcl method")
|
||||
// },
|
||||
// PutBucketCorsFunc: func(contextMoqParam context.Context, bytes []byte) error {
|
||||
// PutBucketCorsFunc: func(contextMoqParam context.Context, bucket string, cors []byte) error {
|
||||
// panic("mock out the PutBucketCors method")
|
||||
// },
|
||||
// PutBucketOwnershipControlsFunc: func(contextMoqParam context.Context, bucket string, ownership types.ObjectOwnership) error {
|
||||
@@ -196,13 +196,13 @@ type BackendMock struct {
|
||||
AbortMultipartUploadFunc func(contextMoqParam context.Context, abortMultipartUploadInput *s3.AbortMultipartUploadInput) error
|
||||
|
||||
// ChangeBucketOwnerFunc mocks the ChangeBucketOwner method.
|
||||
ChangeBucketOwnerFunc func(contextMoqParam context.Context, bucket string, acl []byte) error
|
||||
ChangeBucketOwnerFunc func(contextMoqParam context.Context, bucket string, owner string) error
|
||||
|
||||
// CompleteMultipartUploadFunc mocks the CompleteMultipartUpload method.
|
||||
CompleteMultipartUploadFunc func(contextMoqParam context.Context, completeMultipartUploadInput *s3.CompleteMultipartUploadInput) (*s3.CompleteMultipartUploadOutput, error)
|
||||
CompleteMultipartUploadFunc func(contextMoqParam context.Context, completeMultipartUploadInput *s3.CompleteMultipartUploadInput) (s3response.CompleteMultipartUploadResult, string, error)
|
||||
|
||||
// CopyObjectFunc mocks the CopyObject method.
|
||||
CopyObjectFunc func(contextMoqParam context.Context, copyObjectInput s3response.CopyObjectInput) (*s3.CopyObjectOutput, error)
|
||||
CopyObjectFunc func(contextMoqParam context.Context, copyObjectInput s3response.CopyObjectInput) (s3response.CopyObjectOutput, error)
|
||||
|
||||
// CreateBucketFunc mocks the CreateBucket method.
|
||||
CreateBucketFunc func(contextMoqParam context.Context, createBucketInput *s3.CreateBucketInput, defaultACL []byte) error
|
||||
@@ -304,7 +304,7 @@ type BackendMock struct {
|
||||
PutBucketAclFunc func(contextMoqParam context.Context, bucket string, data []byte) error
|
||||
|
||||
// PutBucketCorsFunc mocks the PutBucketCors method.
|
||||
PutBucketCorsFunc func(contextMoqParam context.Context, bytes []byte) error
|
||||
PutBucketCorsFunc func(contextMoqParam context.Context, bucket string, cors []byte) error
|
||||
|
||||
// PutBucketOwnershipControlsFunc mocks the PutBucketOwnershipControls method.
|
||||
PutBucketOwnershipControlsFunc func(contextMoqParam context.Context, bucket string, ownership types.ObjectOwnership) error
|
||||
@@ -369,8 +369,8 @@ type BackendMock struct {
|
||||
ContextMoqParam context.Context
|
||||
// Bucket is the bucket argument value.
|
||||
Bucket string
|
||||
// ACL is the acl argument value.
|
||||
ACL []byte
|
||||
// Owner is the owner argument value.
|
||||
Owner string
|
||||
}
|
||||
// CompleteMultipartUpload holds details about calls to the CompleteMultipartUpload method.
|
||||
CompleteMultipartUpload []struct {
|
||||
@@ -635,8 +635,10 @@ type BackendMock struct {
|
||||
PutBucketCors []struct {
|
||||
// ContextMoqParam is the contextMoqParam argument value.
|
||||
ContextMoqParam context.Context
|
||||
// Bytes is the bytes argument value.
|
||||
Bytes []byte
|
||||
// Bucket is the bucket argument value.
|
||||
Bucket string
|
||||
// Cors is the cors argument value.
|
||||
Cors []byte
|
||||
}
|
||||
// PutBucketOwnershipControls holds details about calls to the PutBucketOwnershipControls method.
|
||||
PutBucketOwnershipControls []struct {
|
||||
@@ -864,23 +866,23 @@ func (mock *BackendMock) AbortMultipartUploadCalls() []struct {
|
||||
}
|
||||
|
||||
// ChangeBucketOwner calls ChangeBucketOwnerFunc.
|
||||
func (mock *BackendMock) ChangeBucketOwner(contextMoqParam context.Context, bucket string, acl []byte) error {
|
||||
func (mock *BackendMock) ChangeBucketOwner(contextMoqParam context.Context, bucket string, owner string) error {
|
||||
if mock.ChangeBucketOwnerFunc == nil {
|
||||
panic("BackendMock.ChangeBucketOwnerFunc: method is nil but Backend.ChangeBucketOwner was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
ACL []byte
|
||||
Owner string
|
||||
}{
|
||||
ContextMoqParam: contextMoqParam,
|
||||
Bucket: bucket,
|
||||
ACL: acl,
|
||||
Owner: owner,
|
||||
}
|
||||
mock.lockChangeBucketOwner.Lock()
|
||||
mock.calls.ChangeBucketOwner = append(mock.calls.ChangeBucketOwner, callInfo)
|
||||
mock.lockChangeBucketOwner.Unlock()
|
||||
return mock.ChangeBucketOwnerFunc(contextMoqParam, bucket, acl)
|
||||
return mock.ChangeBucketOwnerFunc(contextMoqParam, bucket, owner)
|
||||
}
|
||||
|
||||
// ChangeBucketOwnerCalls gets all the calls that were made to ChangeBucketOwner.
|
||||
@@ -890,12 +892,12 @@ func (mock *BackendMock) ChangeBucketOwner(contextMoqParam context.Context, buck
|
||||
func (mock *BackendMock) ChangeBucketOwnerCalls() []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
ACL []byte
|
||||
Owner string
|
||||
} {
|
||||
var calls []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
ACL []byte
|
||||
Owner string
|
||||
}
|
||||
mock.lockChangeBucketOwner.RLock()
|
||||
calls = mock.calls.ChangeBucketOwner
|
||||
@@ -904,7 +906,7 @@ func (mock *BackendMock) ChangeBucketOwnerCalls() []struct {
|
||||
}
|
||||
|
||||
// CompleteMultipartUpload calls CompleteMultipartUploadFunc.
|
||||
func (mock *BackendMock) CompleteMultipartUpload(contextMoqParam context.Context, completeMultipartUploadInput *s3.CompleteMultipartUploadInput) (*s3.CompleteMultipartUploadOutput, error) {
|
||||
func (mock *BackendMock) CompleteMultipartUpload(contextMoqParam context.Context, completeMultipartUploadInput *s3.CompleteMultipartUploadInput) (s3response.CompleteMultipartUploadResult, string, error) {
|
||||
if mock.CompleteMultipartUploadFunc == nil {
|
||||
panic("BackendMock.CompleteMultipartUploadFunc: method is nil but Backend.CompleteMultipartUpload was just called")
|
||||
}
|
||||
@@ -940,7 +942,7 @@ func (mock *BackendMock) CompleteMultipartUploadCalls() []struct {
|
||||
}
|
||||
|
||||
// CopyObject calls CopyObjectFunc.
|
||||
func (mock *BackendMock) CopyObject(contextMoqParam context.Context, copyObjectInput s3response.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||
func (mock *BackendMock) CopyObject(contextMoqParam context.Context, copyObjectInput s3response.CopyObjectInput) (s3response.CopyObjectOutput, error) {
|
||||
if mock.CopyObjectFunc == nil {
|
||||
panic("BackendMock.CopyObjectFunc: method is nil but Backend.CopyObject was just called")
|
||||
}
|
||||
@@ -2192,21 +2194,23 @@ func (mock *BackendMock) PutBucketAclCalls() []struct {
|
||||
}
|
||||
|
||||
// PutBucketCors calls PutBucketCorsFunc.
|
||||
func (mock *BackendMock) PutBucketCors(contextMoqParam context.Context, bytes []byte) error {
|
||||
func (mock *BackendMock) PutBucketCors(contextMoqParam context.Context, bucket string, cors []byte) error {
|
||||
if mock.PutBucketCorsFunc == nil {
|
||||
panic("BackendMock.PutBucketCorsFunc: method is nil but Backend.PutBucketCors was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
ContextMoqParam context.Context
|
||||
Bytes []byte
|
||||
Bucket string
|
||||
Cors []byte
|
||||
}{
|
||||
ContextMoqParam: contextMoqParam,
|
||||
Bytes: bytes,
|
||||
Bucket: bucket,
|
||||
Cors: cors,
|
||||
}
|
||||
mock.lockPutBucketCors.Lock()
|
||||
mock.calls.PutBucketCors = append(mock.calls.PutBucketCors, callInfo)
|
||||
mock.lockPutBucketCors.Unlock()
|
||||
return mock.PutBucketCorsFunc(contextMoqParam, bytes)
|
||||
return mock.PutBucketCorsFunc(contextMoqParam, bucket, cors)
|
||||
}
|
||||
|
||||
// PutBucketCorsCalls gets all the calls that were made to PutBucketCors.
|
||||
@@ -2215,11 +2219,13 @@ func (mock *BackendMock) PutBucketCors(contextMoqParam context.Context, bytes []
|
||||
// len(mockedBackend.PutBucketCorsCalls())
|
||||
func (mock *BackendMock) PutBucketCorsCalls() []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bytes []byte
|
||||
Bucket string
|
||||
Cors []byte
|
||||
} {
|
||||
var calls []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bytes []byte
|
||||
Bucket string
|
||||
Cors []byte
|
||||
}
|
||||
mock.lockPutBucketCors.RLock()
|
||||
calls = mock.calls.PutBucketCors
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
194
s3api/controllers/bucket-delete.go
Normal file
194
s3api/controllers/bucket-delete.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
)
|
||||
|
||||
func (c S3ApiController) DeleteBucketTagging(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketTaggingAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.DeleteBucketTagging(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) DeleteBucketOwnershipControls(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketOwnershipControlsAction,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.DeleteBucketOwnershipControls(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) DeleteBucketPolicy(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.DeleteBucketPolicyAction,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.DeleteBucketPolicy(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) DeleteBucketCors(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketCorsAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.DeleteBucketCors(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) DeleteBucket(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.DeleteBucketAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.DeleteBucket(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
413
s3api/controllers/bucket-delete_test.go
Normal file
413
s3api/controllers/bucket-delete_test.go
Normal file
@@ -0,0 +1,413 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func TestS3ApiController_DeleteBucketTagging(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrAclNotSupported),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAclNotSupported),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
DeleteBucketTaggingFunc: func(_ context.Context, _ string) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteBucketTagging,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_DeleteBucketOwnershipControls(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidAccessKeyID),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidAccessKeyID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
DeleteBucketOwnershipControlsFunc: func(contextMoqParam context.Context, bucket string) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteBucketOwnershipControls,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_DeleteBucketPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidDigest),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidDigest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
DeleteBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteBucketPolicy,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_DeleteBucketCors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrAdminMethodNotSupported),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAdminMethodNotSupported),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
DeleteBucketCorsFunc: func(contextMoqParam context.Context, bucket string) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteBucketCors,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_DeleteBucket(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidDigest),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidDigest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
DeleteBucketFunc: func(contextMoqParam context.Context, bucket string) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteBucket,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
670
s3api/controllers/bucket-get.go
Normal file
670
s3api/controllers/bucket-get.go
Normal file
@@ -0,0 +1,670 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func (c S3ApiController) GetBucketTagging(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketTaggingAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
tags, err := c.be.GetBucketTagging(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
resp := s3response.Tagging{
|
||||
TagSet: s3response.TagSet{
|
||||
Tags: make([]s3response.Tag, 0, len(tags)),
|
||||
},
|
||||
}
|
||||
|
||||
for key, val := range tags {
|
||||
resp.TagSet.Tags = append(resp.TagSet.Tags,
|
||||
s3response.Tag{Key: key, Value: val})
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Data: resp,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetBucketOwnershipControls(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketOwnershipControlsAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetBucketOwnershipControls(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
Data: s3response.OwnershipControls{
|
||||
Rules: []types.OwnershipControlsRule{
|
||||
{
|
||||
ObjectOwnership: data,
|
||||
},
|
||||
},
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetBucketVersioning(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketVersioningAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
// Only admin users and the bucket owner are allowed to get the versioning state of a bucket.
|
||||
if err := auth.IsAdminOrOwner(acct, isRoot, parsedAcl); err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetBucketVersioning(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
Data: data,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetBucketCors(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketCorsAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetBucketCors(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
output, err := auth.ParseCORSOutput(data)
|
||||
return &Response{
|
||||
Data: output,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetBucketPolicy(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketPolicyAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetBucketPolicy(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
Data: data,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetBucketPolicyStatus(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketPolicyStatusAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
policyRaw, err := c.be.GetBucketPolicy(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
policy, err := auth.ParsePolicyDocument(policyRaw)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
isPublic := policy.IsPublic()
|
||||
|
||||
return &Response{
|
||||
Data: types.PolicyStatus{
|
||||
IsPublic: &isPublic,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c S3ApiController) ListObjectVersions(ctx *fiber.Ctx) (*Response, error) {
|
||||
// url values
|
||||
bucket := ctx.Params("bucket")
|
||||
prefix := ctx.Query("prefix")
|
||||
delimiter := ctx.Query("delimiter")
|
||||
maxkeysStr := ctx.Query("max-keys")
|
||||
keyMarker := ctx.Query("key-marker")
|
||||
versionIdMarker := ctx.Query("version-id-marker")
|
||||
// context keys
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.ListBucketVersionsAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
maxkeys, err := utils.ParseUint(maxkeysStr)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error parsing max keys %q: %v",
|
||||
maxkeysStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidMaxKeys)
|
||||
}
|
||||
|
||||
data, err := c.be.ListObjectVersions(ctx.Context(),
|
||||
&s3.ListObjectVersionsInput{
|
||||
Bucket: &bucket,
|
||||
Delimiter: &delimiter,
|
||||
KeyMarker: &keyMarker,
|
||||
MaxKeys: &maxkeys,
|
||||
Prefix: &prefix,
|
||||
VersionIdMarker: &versionIdMarker,
|
||||
})
|
||||
return &Response{
|
||||
Data: data,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetObjectLockConfiguration(ctx *fiber.Ctx) (*Response, error) {
|
||||
// url values
|
||||
bucket := ctx.Params("bucket")
|
||||
// context keys
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketObjectLockConfigurationAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetObjectLockConfiguration(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
resp, err := auth.ParseBucketLockConfigurationOutput(data)
|
||||
return &Response{
|
||||
Data: resp,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetBucketAcl(ctx *fiber.Ctx) (*Response, error) {
|
||||
// url values
|
||||
bucket := ctx.Params("bucket")
|
||||
// context keys
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionReadAcp,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketAclAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetBucketAcl(ctx.Context(),
|
||||
&s3.GetBucketAclInput{Bucket: &bucket})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
res, err := auth.ParseACLOutput(data, parsedAcl.Owner)
|
||||
return &Response{
|
||||
Data: res,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) ListMultipartUploads(ctx *fiber.Ctx) (*Response, error) {
|
||||
// url values
|
||||
bucket := ctx.Params("bucket")
|
||||
prefix := ctx.Query("prefix")
|
||||
delimiter := ctx.Query("delimiter")
|
||||
keyMarker := ctx.Query("key-marker")
|
||||
maxUploadsStr := ctx.Query("max-uploads")
|
||||
uploadIdMarker := ctx.Query("upload-id-marker")
|
||||
// context keys
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.ListBucketMultipartUploadsAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
maxUploads, err := utils.ParseUint(maxUploadsStr)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error parsing max uploads %q: %v",
|
||||
maxUploadsStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidMaxUploads)
|
||||
}
|
||||
res, err := c.be.ListMultipartUploads(ctx.Context(),
|
||||
&s3.ListMultipartUploadsInput{
|
||||
Bucket: &bucket,
|
||||
Delimiter: &delimiter,
|
||||
Prefix: &prefix,
|
||||
UploadIdMarker: &uploadIdMarker,
|
||||
MaxUploads: &maxUploads,
|
||||
KeyMarker: &keyMarker,
|
||||
})
|
||||
return &Response{
|
||||
Data: res,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) ListObjectsV2(ctx *fiber.Ctx) (*Response, error) {
|
||||
// url values
|
||||
bucket := ctx.Params("bucket")
|
||||
prefix := ctx.Query("prefix")
|
||||
cToken := ctx.Query("continuation-token")
|
||||
sAfter := ctx.Query("start-after")
|
||||
delimiter := ctx.Query("delimiter")
|
||||
maxkeysStr := ctx.Query("max-keys")
|
||||
fetchOwner := strings.EqualFold(ctx.Query("fetch-owner"), "true")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.ListBucketAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
maxkeys, err := utils.ParseUint(maxkeysStr)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error parsing max keys %q: %v",
|
||||
maxkeysStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidMaxKeys)
|
||||
}
|
||||
|
||||
res, err := c.be.ListObjectsV2(ctx.Context(),
|
||||
&s3.ListObjectsV2Input{
|
||||
Bucket: &bucket,
|
||||
Prefix: &prefix,
|
||||
ContinuationToken: &cToken,
|
||||
Delimiter: &delimiter,
|
||||
MaxKeys: &maxkeys,
|
||||
StartAfter: &sAfter,
|
||||
FetchOwner: &fetchOwner,
|
||||
})
|
||||
return &Response{
|
||||
Data: res,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) ListObjects(ctx *fiber.Ctx) (*Response, error) {
|
||||
// url values
|
||||
bucket := ctx.Params("bucket")
|
||||
prefix := ctx.Query("prefix")
|
||||
marker := ctx.Query("marker")
|
||||
delimiter := ctx.Query("delimiter")
|
||||
maxkeysStr := ctx.Query("max-keys")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.ListBucketAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
maxkeys, err := utils.ParseUint(maxkeysStr)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error parsing max keys %q: %v",
|
||||
maxkeysStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidMaxKeys)
|
||||
}
|
||||
|
||||
res, err := c.be.ListObjects(ctx.Context(),
|
||||
&s3.ListObjectsInput{
|
||||
Bucket: &bucket,
|
||||
Prefix: &prefix,
|
||||
Marker: &marker,
|
||||
Delimiter: &delimiter,
|
||||
MaxKeys: &maxkeys,
|
||||
})
|
||||
return &Response{
|
||||
Data: res,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// GetBucketLocation handles GET /:bucket?location
|
||||
func (c S3ApiController) GetBucketLocation(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketLocationAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// verify bucket existence/access via backend HeadBucket
|
||||
_, err = c.be.HeadBucket(ctx.Context(), &s3.HeadBucketInput{Bucket: &bucket})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// pick up configured region from locals (set by router middleware)
|
||||
region, _ := ctx.Locals("region").(string)
|
||||
|
||||
return &Response{
|
||||
Data: s3response.LocationConstraint{
|
||||
Value: region,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
1348
s3api/controllers/bucket-get_test.go
Normal file
1348
s3api/controllers/bucket-get_test.go
Normal file
File diff suppressed because it is too large
Load Diff
73
s3api/controllers/bucket-head.go
Normal file
73
s3api/controllers/bucket-head.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
)
|
||||
|
||||
func (c S3ApiController) HeadBucket(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
region := utils.ContextKeyRegion.Get(ctx).(string)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.ListBucketAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
_, err = c.be.HeadBucket(ctx.Context(),
|
||||
&s3.HeadBucketInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Headers: map[string]*string{
|
||||
"X-Amz-Access-Point-Alias": utils.GetStringPtr("false"),
|
||||
"X-Amz-Bucket-Region": utils.GetStringPtr(region),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
136
s3api/controllers/bucket-head_test.go
Normal file
136
s3api/controllers/bucket-head_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func TestS3ApiController_HeadBucket(t *testing.T) {
|
||||
region := "us-east-1"
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: map[utils.ContextKey]any{
|
||||
utils.ContextKeyIsRoot: false,
|
||||
utils.ContextKeyParsedAcl: auth.ACL{
|
||||
Owner: "root",
|
||||
},
|
||||
utils.ContextKeyAccount: auth.Account{
|
||||
Access: "user",
|
||||
Role: auth.RoleUser,
|
||||
},
|
||||
utils.ContextKeyRegion: region,
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: map[utils.ContextKey]any{
|
||||
utils.ContextKeyIsRoot: true,
|
||||
utils.ContextKeyParsedAcl: auth.ACL{
|
||||
Owner: "root",
|
||||
},
|
||||
utils.ContextKeyAccount: auth.Account{
|
||||
Access: "root",
|
||||
Role: auth.RoleAdmin,
|
||||
},
|
||||
utils.ContextKeyRegion: region,
|
||||
},
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidAccessKeyID),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidAccessKeyID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: map[utils.ContextKey]any{
|
||||
utils.ContextKeyIsRoot: true,
|
||||
utils.ContextKeyParsedAcl: auth.ACL{
|
||||
Owner: "root",
|
||||
},
|
||||
utils.ContextKeyAccount: auth.Account{
|
||||
Access: "root",
|
||||
Role: auth.RoleAdmin,
|
||||
},
|
||||
utils.ContextKeyRegion: region,
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"X-Amz-Access-Point-Alias": utils.GetStringPtr("false"),
|
||||
"X-Amz-Bucket-Region": utils.GetStringPtr(region),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
HeadBucketFunc: func(contextMoqParam context.Context, headBucketInput *s3.HeadBucketInput) (*s3.HeadBucketOutput, error) {
|
||||
return &s3.HeadBucketOutput{}, tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.HeadBucket,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
69
s3api/controllers/bucket-list.go
Normal file
69
s3api/controllers/bucket-list.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func (c S3ApiController) ListBuckets(ctx *fiber.Ctx) (*Response, error) {
|
||||
cToken := ctx.Query("continuation-token")
|
||||
prefix := ctx.Query("prefix")
|
||||
maxBucketsStr := ctx.Query("max-buckets")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
region, ok := utils.ContextKeyRegion.Get(ctx).(string)
|
||||
if !ok {
|
||||
region = defaultRegion
|
||||
}
|
||||
|
||||
maxBuckets := defaultMaxBuckets
|
||||
if maxBucketsStr != "" {
|
||||
maxBucketsParsed, err := strconv.ParseInt(maxBucketsStr, 10, 32)
|
||||
if err != nil || maxBucketsParsed < 0 || maxBucketsParsed > int64(defaultMaxBuckets) {
|
||||
debuglogger.Logf("error parsing max-buckets %q: %v", maxBucketsStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidMaxBuckets)
|
||||
}
|
||||
maxBuckets = int32(maxBucketsParsed)
|
||||
}
|
||||
|
||||
res, err := c.be.ListBuckets(ctx.Context(),
|
||||
s3response.ListBucketsInput{
|
||||
Owner: acct.Access,
|
||||
IsAdmin: acct.Role == auth.RoleAdmin,
|
||||
MaxBuckets: maxBuckets,
|
||||
ContinuationToken: cToken,
|
||||
Prefix: prefix,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{}, err
|
||||
}
|
||||
|
||||
for i := range res.Buckets.Bucket {
|
||||
res.Buckets.Bucket[i].BucketRegion = region
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Data: res,
|
||||
}, nil
|
||||
}
|
||||
108
s3api/controllers/bucket-list_test.go
Normal file
108
s3api/controllers/bucket-list_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func TestS3ApiController_ListBuckets(t *testing.T) {
|
||||
validRes := s3response.ListAllMyBucketsResult{
|
||||
Owner: s3response.CanonicalUser{
|
||||
ID: "root",
|
||||
},
|
||||
Buckets: s3response.ListAllMyBucketsList{
|
||||
Bucket: []s3response.ListAllMyBucketsEntry{
|
||||
{Name: "test"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "invalid max buckets",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
queries: map[string]string{
|
||||
"max-buckets": "-1",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidMaxBuckets),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
beRes: s3response.ListAllMyBucketsResult{},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: validRes,
|
||||
queries: map[string]string{
|
||||
"max-buckets": "3",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: validRes,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
ListBucketsFunc: func(contextMoqParam context.Context, listBucketsInput s3response.ListBucketsInput) (s3response.ListAllMyBucketsResult, error) {
|
||||
return tt.input.beRes.(s3response.ListAllMyBucketsResult), tt.input.beErr
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.ListBuckets,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
queries: tt.input.queries,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
94
s3api/controllers/bucket-post.go
Normal file
94
s3api/controllers/bucket-post.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func (c S3ApiController) DeleteObjects(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
bypass := strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.DeleteObjectAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var dObj s3response.DeleteObjects
|
||||
err = xml.Unmarshal(ctx.Body(), &dObj)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error unmarshalling delete objects: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
err = auth.CheckObjectAccess(ctx.Context(), bucket, acct.Access, dObj.Objects, bypass, IsBucketPublic, c.be)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
res, err := c.be.DeleteObjects(ctx.Context(),
|
||||
&s3.DeleteObjectsInput{
|
||||
Bucket: &bucket,
|
||||
Delete: &types.Delete{
|
||||
Objects: dObj.Objects,
|
||||
},
|
||||
})
|
||||
return &Response{
|
||||
Data: res,
|
||||
MetaOpts: &MetaOptions{
|
||||
ObjectCount: int64(len(dObj.Objects)),
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
EventName: s3event.EventObjectRemovedDeleteObjects,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
165
s3api/controllers/bucket-post_test.go
Normal file
165
s3api/controllers/bucket-post_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func TestS3ApiController_DeleteObjects(t *testing.T) {
|
||||
validBody, err := xml.Marshal(s3response.DeleteObjects{
|
||||
Objects: []types.ObjectIdentifier{
|
||||
{Key: utils.GetStringPtr("obj")},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
validRes := s3response.DeleteResult{
|
||||
Deleted: []types.DeletedObject{
|
||||
{Key: utils.GetStringPtr("key")},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid request body",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: []byte("invalid_body"),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check object access returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validBody,
|
||||
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLocked),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrObjectLocked),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: s3response.DeleteResult{},
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
body: validBody,
|
||||
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.DeleteResult{},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
EventName: s3event.EventObjectRemovedDeleteObjects,
|
||||
ObjectCount: 1,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validBody,
|
||||
beRes: validRes,
|
||||
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: validRes,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
EventName: s3event.EventObjectRemovedDeleteObjects,
|
||||
ObjectCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
DeleteObjectsFunc: func(contextMoqParam context.Context, deleteObjectsInput *s3.DeleteObjectsInput) (s3response.DeleteResult, error) {
|
||||
return tt.input.beRes.(s3response.DeleteResult), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
GetObjectLockConfigurationFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, tt.input.extraMockErr
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteObjects,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
603
s3api/controllers/bucket-put.go
Normal file
603
s3api/controllers/bucket-put.go
Normal file
@@ -0,0 +1,603 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func (c S3ApiController) PutBucketTagging(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketTaggingAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
tagging, err := utils.ParseTagging(ctx.Body(), utils.TagLimitBucket)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.PutBucketTagging(ctx.Context(), bucket, tagging)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutBucketOwnershipControls(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
|
||||
if err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketOwnershipControlsAction,
|
||||
}); err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var ownershipControls s3response.OwnershipControls
|
||||
if err := xml.Unmarshal(ctx.Body(), &ownershipControls); err != nil {
|
||||
debuglogger.Logf("failed to unmarshal request body: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
rulesCount := len(ownershipControls.Rules)
|
||||
isValidOwnership := utils.IsValidOwnership(ownershipControls.Rules[0].ObjectOwnership)
|
||||
if rulesCount != 1 || !isValidOwnership {
|
||||
if rulesCount != 1 {
|
||||
debuglogger.Logf("ownership control rules should be 1, got %v", rulesCount)
|
||||
}
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
err := c.be.PutBucketOwnershipControls(ctx.Context(), bucket, ownershipControls.Rules[0].ObjectOwnership)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutBucketVersioning(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketVersioningAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var versioningConf types.VersioningConfiguration
|
||||
err = xml.Unmarshal(ctx.Body(), &versioningConf)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error unmarshalling versioning configuration: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
if versioningConf.Status != types.BucketVersioningStatusEnabled &&
|
||||
versioningConf.Status != types.BucketVersioningStatusSuspended {
|
||||
debuglogger.Logf("invalid versioning configuration status: %v", versioningConf.Status)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
err = c.be.PutBucketVersioning(ctx.Context(), bucket, versioningConf.Status)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutObjectLockConfiguration(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
if err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketObjectLockConfigurationAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
}); err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
config, err := auth.ParseBucketLockConfigurationInput(ctx.Body())
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.PutObjectLockConfiguration(ctx.Context(), bucket, config)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutBucketCors(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketCorsAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
body := ctx.Body()
|
||||
|
||||
var corsConfig auth.CORSConfiguration
|
||||
err = xml.Unmarshal(body, &corsConfig)
|
||||
if err != nil {
|
||||
debuglogger.Logf("invalid CORS request body: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
// validate the CORS configuration rules
|
||||
err = corsConfig.Validate()
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
algo, checksusms, err := utils.ParseChecksumHeadersAndSdkAlgo(ctx)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
if algo != "" {
|
||||
rdr, err := utils.NewHashReader(bytes.NewReader(body), checksusms[algo], utils.HashType(strings.ToLower(string(algo))))
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// Pass the same body to avoid data duplication
|
||||
_, err = rdr.Read(body)
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to read hash calculation data: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
}
|
||||
|
||||
err = c.be.PutBucketCors(ctx.Context(), bucket, body)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutBucketPolicy(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketPolicyAction,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = auth.ValidatePolicyDocument(ctx.Body(), bucket, c.iam)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.PutBucketPolicy(ctx.Context(), bucket, ctx.Body())
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutBucketAcl(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acl := ctx.Get("X-Amz-Acl")
|
||||
grantFullControl := ctx.Get("X-Amz-Grant-Full-Control")
|
||||
grantRead := ctx.Get("X-Amz-Grant-Read")
|
||||
grantReadACP := ctx.Get("X-Amz-Grant-Read-Acp")
|
||||
grantWrite := ctx.Get("X-Amz-Grant-Write")
|
||||
grantWriteACP := ctx.Get("X-Amz-Grant-Write-Acp")
|
||||
// context locals
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
|
||||
grants := grantFullControl + grantRead + grantReadACP + grantWrite + grantWriteACP
|
||||
var input *auth.PutBucketAclInput
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWriteAcp,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketAclAction,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
ownership, err := c.be.GetBucketOwnershipControls(ctx.Context(), bucket)
|
||||
if err != nil && !errors.Is(err, s3err.GetAPIError(s3err.ErrOwnershipControlsNotFound)) {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
if ownership == types.ObjectOwnershipBucketOwnerEnforced {
|
||||
debuglogger.Logf("bucket acls are disabled")
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrAclNotSupported)
|
||||
}
|
||||
|
||||
if len(ctx.Body()) > 0 {
|
||||
var accessControlPolicy auth.AccessControlPolicy
|
||||
err := xml.Unmarshal(ctx.Body(), &accessControlPolicy)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error unmarshalling access control policy: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedACL)
|
||||
}
|
||||
|
||||
err = accessControlPolicy.Validate()
|
||||
if err != nil {
|
||||
debuglogger.Logf("invalid access control policy: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
if *accessControlPolicy.Owner.ID != parsedAcl.Owner {
|
||||
debuglogger.Logf("invalid access control policy owner id: %v, expected %v", *accessControlPolicy.Owner.ID, parsedAcl.Owner)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.APIError{
|
||||
Code: "InvalidArgument",
|
||||
Description: "Invalid id",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
if grants+acl != "" {
|
||||
debuglogger.Logf("invalid request: %q (grants) %q (acl)",
|
||||
grants, acl)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrUnexpectedContent)
|
||||
}
|
||||
|
||||
input = &auth.PutBucketAclInput{
|
||||
Bucket: &bucket,
|
||||
AccessControlPolicy: &accessControlPolicy,
|
||||
}
|
||||
} else if acl != "" {
|
||||
if acl != "private" && acl != "public-read" && acl != "public-read-write" {
|
||||
debuglogger.Logf("invalid acl: %q", acl)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidRequest)
|
||||
}
|
||||
if grants != "" {
|
||||
debuglogger.Logf("invalid request: %q (grants) %q (acl)",
|
||||
grants, acl)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrBothCannedAndHeaderGrants)
|
||||
}
|
||||
|
||||
input = &auth.PutBucketAclInput{
|
||||
Bucket: &bucket,
|
||||
ACL: types.BucketCannedACL(acl),
|
||||
}
|
||||
} else if grants != "" {
|
||||
input = &auth.PutBucketAclInput{
|
||||
Bucket: &bucket,
|
||||
GrantFullControl: &grantFullControl,
|
||||
GrantRead: &grantRead,
|
||||
GrantReadACP: &grantReadACP,
|
||||
GrantWrite: &grantWrite,
|
||||
GrantWriteACP: &grantWriteACP,
|
||||
}
|
||||
} else {
|
||||
debuglogger.Logf("none of the bucket acl options has been specified: canned, req headers, req body")
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMissingSecurityHeader)
|
||||
}
|
||||
|
||||
updAcl, err := auth.UpdateACL(input, parsedAcl, c.iam, acct.Role == auth.RoleAdmin)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.PutBucketAcl(ctx.Context(), bucket, updAcl)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) CreateBucket(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acl := ctx.Get("X-Amz-Acl")
|
||||
grantFullControl := ctx.Get("X-Amz-Grant-Full-Control")
|
||||
grantRead := ctx.Get("X-Amz-Grant-Read")
|
||||
grantReadACP := ctx.Get("X-Amz-Grant-Read-Acp")
|
||||
grantWrite := ctx.Get("X-Amz-Grant-Write")
|
||||
grantWriteACP := ctx.Get("X-Amz-Grant-Write-Acp")
|
||||
lockEnabled := strings.EqualFold(ctx.Get("X-Amz-Bucket-Object-Lock-Enabled"), "true")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
grants := grantFullControl + grantRead + grantReadACP + grantWrite + grantWriteACP
|
||||
objectOwnership := types.ObjectOwnership(
|
||||
ctx.Get("X-Amz-Object-Ownership", string(types.ObjectOwnershipBucketOwnerEnforced)),
|
||||
)
|
||||
|
||||
if acct.Role != auth.RoleAdmin && acct.Role != auth.RoleUserPlus {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
// validate the bucket name
|
||||
if ok := utils.IsValidBucketName(bucket); !ok {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidBucketName)
|
||||
}
|
||||
|
||||
// validate the object ownership value
|
||||
if ok := utils.IsValidOwnership(objectOwnership); !ok {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{},
|
||||
}, s3err.APIError{
|
||||
Code: "InvalidArgument",
|
||||
Description: fmt.Sprintf("Invalid x-amz-object-ownership header: %v", objectOwnership),
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
if acl+grants != "" && objectOwnership == types.ObjectOwnershipBucketOwnerEnforced {
|
||||
debuglogger.Logf("bucket acls are disabled for %v object ownership", objectOwnership)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: acct.Access,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidBucketAclWithObjectOwnership)
|
||||
}
|
||||
|
||||
if acl != "" && grants != "" {
|
||||
debuglogger.Logf("invalid request: %q (grants) %q (acl)", grants, acl)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: acct.Access,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrBothCannedAndHeaderGrants)
|
||||
}
|
||||
|
||||
defACL := auth.ACL{
|
||||
Owner: acct.Access,
|
||||
}
|
||||
|
||||
updAcl, err := auth.UpdateACL(&auth.PutBucketAclInput{
|
||||
GrantFullControl: &grantFullControl,
|
||||
GrantRead: &grantRead,
|
||||
GrantReadACP: &grantReadACP,
|
||||
GrantWrite: &grantWrite,
|
||||
GrantWriteACP: &grantWriteACP,
|
||||
AccessControlPolicy: &auth.AccessControlPolicy{
|
||||
Owner: &types.Owner{
|
||||
ID: &acct.Access,
|
||||
}},
|
||||
ACL: types.BucketCannedACL(acl),
|
||||
}, defACL, c.iam, acct.Role == auth.RoleAdmin)
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to update bucket acl: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: acct.Access,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.CreateBucket(ctx.Context(), &s3.CreateBucketInput{
|
||||
Bucket: &bucket,
|
||||
ObjectOwnership: objectOwnership,
|
||||
ObjectLockEnabledForBucket: &lockEnabled,
|
||||
}, updAcl)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: acct.Access,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
1167
s3api/controllers/bucket-put_test.go
Normal file
1167
s3api/controllers/bucket-put_test.go
Normal file
File diff suppressed because it is too large
Load Diff
206
s3api/controllers/object-delete.go
Normal file
206
s3api/controllers/object-delete.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
)
|
||||
|
||||
func (c S3ApiController) DeleteObjectTagging(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.DeleteObjectTaggingAction,
|
||||
IsPublicRequest: isBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.DeleteObjectTagging(ctx.Context(), bucket, key)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
Status: http.StatusNoContent,
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
EventName: s3event.EventObjectTaggingDelete,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) AbortMultipartUpload(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
uploadId := ctx.Query("uploadId")
|
||||
ifMatchInitiatedTime := utils.ParsePreconditionDateHeader(ctx.Get("X-Amz-If-Match-Initiated-Time"))
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.AbortMultipartUploadAction,
|
||||
IsPublicRequest: isBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.AbortMultipartUpload(ctx.Context(),
|
||||
&s3.AbortMultipartUploadInput{
|
||||
UploadId: &uploadId,
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
IfMatchInitiatedTime: ifMatchInitiatedTime,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) DeleteObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
versionId := ctx.Query("versionId")
|
||||
bypass := strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true")
|
||||
ifMatch := utils.GetStringPtr(ctx.Get("If-Match"))
|
||||
ifMatchLastModTime := utils.ParsePreconditionDateHeader(ctx.Get("X-Amz-If-Match-Last-Modified-Time"))
|
||||
ifMatchSize := utils.ParseIfMatchSize(ctx)
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
//TODO: check s3:DeleteObjectVersion policy in case a use tries to delete a version of an object
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.DeleteObjectAction,
|
||||
IsPublicRequest: isBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = auth.CheckObjectAccess(
|
||||
ctx.Context(),
|
||||
bucket,
|
||||
acct.Access,
|
||||
[]types.ObjectIdentifier{
|
||||
{
|
||||
Key: &key,
|
||||
VersionId: &versionId,
|
||||
},
|
||||
},
|
||||
bypass,
|
||||
isBucketPublic,
|
||||
c.be,
|
||||
)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
res, err := c.be.DeleteObject(ctx.Context(),
|
||||
&s3.DeleteObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
VersionId: &versionId,
|
||||
IfMatch: ifMatch,
|
||||
IfMatchLastModifiedTime: ifMatchLastModTime,
|
||||
IfMatchSize: ifMatchSize,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
EventName: s3event.EventObjectRemovedDelete,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
headers := map[string]*string{
|
||||
"x-amz-version-id": res.VersionId,
|
||||
}
|
||||
|
||||
if res.DeleteMarker != nil && *res.DeleteMarker {
|
||||
headers["x-amz-delete-marker"] = utils.GetStringPtr("true")
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Headers: headers,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
EventName: s3event.EventObjectRemovedDelete,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
296
s3api/controllers/object-delete_test.go
Normal file
296
s3api/controllers/object-delete_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
)
|
||||
|
||||
func TestS3ApiController_DeleteObjectTagging(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
EventName: s3event.EventObjectTaggingDelete,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
EventName: s3event.EventObjectTaggingDelete,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
DeleteObjectTaggingFunc: func(contextMoqParam context.Context, bucket, object string) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteObjectTagging,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_AbortMultipartUpload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
AbortMultipartUploadFunc: func(contextMoqParam context.Context, abortMultipartUploadInput *s3.AbortMultipartUploadInput) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.AbortMultipartUpload,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_DeleteObject(t *testing.T) {
|
||||
delMarker, versionId := true, "versionId"
|
||||
var emptyRes *s3.DeleteObjectOutput
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "object locked",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLocked),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrObjectLocked),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
|
||||
beRes: emptyRes,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
EventName: s3event.EventObjectRemovedDelete,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
|
||||
beRes: &s3.DeleteObjectOutput{
|
||||
DeleteMarker: &delMarker,
|
||||
VersionId: &versionId,
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"x-amz-delete-marker": utils.GetStringPtr("true"),
|
||||
"x-amz-version-id": &versionId,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusNoContent,
|
||||
EventName: s3event.EventObjectRemovedDelete,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
DeleteObjectFunc: func(contextMoqParam context.Context, deleteObjectInput *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
|
||||
return tt.input.beRes.(*s3.DeleteObjectOutput), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
GetObjectLockConfigurationFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, tt.input.extraMockErr
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.DeleteObject,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
560
s3api/controllers/object-get.go
Normal file
560
s3api/controllers/object-get.go
Normal file
@@ -0,0 +1,560 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func (c S3ApiController) GetObjectTagging(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.GetObjectTaggingAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetObjectTagging(ctx.Context(), bucket, key)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
tags := s3response.Tagging{
|
||||
TagSet: s3response.TagSet{Tags: []s3response.Tag{}},
|
||||
}
|
||||
|
||||
for key, val := range data {
|
||||
tags.TagSet.Tags = append(tags.TagSet.Tags,
|
||||
s3response.Tag{Key: key, Value: val})
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Data: tags,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetObjectRetention(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
versionId := ctx.Query("versionId")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.GetObjectRetentionAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetObjectRetention(ctx.Context(), bucket, key, versionId)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
retention, err := auth.ParseObjectLockRetentionOutput(data)
|
||||
return &Response{
|
||||
Data: retention,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetObjectLegalHold(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
versionId := ctx.Query("versionId")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.GetObjectLegalHoldAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetObjectLegalHold(ctx.Context(), bucket, key, versionId)
|
||||
return &Response{
|
||||
Data: auth.ParseObjectLegalHoldOutput(data),
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetObjectAcl(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionReadAcp,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.GetObjectAclAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
res, err := c.be.GetObjectAcl(ctx.Context(), &s3.GetObjectAclInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
})
|
||||
return &Response{
|
||||
Data: res,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) ListParts(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
uploadId := ctx.Query("uploadId")
|
||||
partNumberMarker := ctx.Query("part-number-marker")
|
||||
maxPartsStr := ctx.Query("max-parts")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.ListMultipartUploadPartsAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// parse the part number marker
|
||||
if partNumberMarker != "" {
|
||||
n, err := strconv.Atoi(partNumberMarker)
|
||||
if err != nil || n < 0 {
|
||||
debuglogger.Logf("invalid part number marker %q: %v",
|
||||
partNumberMarker, err)
|
||||
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidPartNumberMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// parse the max parts
|
||||
maxParts, err := utils.ParseUint(maxPartsStr)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error parsing max parts %q: %v",
|
||||
maxPartsStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidMaxParts)
|
||||
}
|
||||
|
||||
res, err := c.be.ListParts(ctx.Context(), &s3.ListPartsInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
UploadId: &uploadId,
|
||||
PartNumberMarker: &partNumberMarker,
|
||||
MaxParts: &maxParts,
|
||||
})
|
||||
return &Response{
|
||||
Data: res,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetObjectAttributes(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
versionId := ctx.Query("versionId")
|
||||
maxPartsStr := ctx.Get("X-Amz-Max-Parts")
|
||||
partNumberMarker := ctx.Get("X-Amz-Part-Number-Marker")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.GetObjectAttributesAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// parse max parts
|
||||
maxParts, err := utils.ParseUint(maxPartsStr)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error parsing max parts %q: %v",
|
||||
maxPartsStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidMaxParts)
|
||||
}
|
||||
|
||||
// parse the object attributes
|
||||
attrs, err := utils.ParseObjectAttributes(ctx)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
res, err := c.be.GetObjectAttributes(ctx.Context(),
|
||||
&s3.GetObjectAttributesInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
PartNumberMarker: &partNumberMarker,
|
||||
MaxParts: &maxParts,
|
||||
VersionId: &versionId,
|
||||
})
|
||||
if err != nil {
|
||||
headers := map[string]*string{
|
||||
"x-amz-version-id": res.VersionId,
|
||||
}
|
||||
if res.DeleteMarker != nil && *res.DeleteMarker {
|
||||
headers["x-amz-delete-marker"] = utils.GetStringPtr("true")
|
||||
}
|
||||
return &Response{
|
||||
Headers: headers,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
headers := map[string]*string{
|
||||
"x-amz-version-id": res.VersionId,
|
||||
"Last-Modified": utils.FormatDatePtrToString(res.LastModified, iso8601TimeFormatExtended),
|
||||
}
|
||||
if res.DeleteMarker != nil && *res.DeleteMarker {
|
||||
headers["x-amz-delete-marker"] = utils.GetStringPtr("true")
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Headers: headers,
|
||||
Data: utils.FilterObjectAttributes(attrs, res),
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
versionId := ctx.Query("versionId")
|
||||
acceptRange := ctx.Get("Range")
|
||||
checksumMode := types.ChecksumMode(strings.ToUpper(ctx.Get("x-amz-checksum-mode")))
|
||||
partNumberQuery := int32(ctx.QueryInt("partNumber", -1))
|
||||
|
||||
// Extract response override query parameters
|
||||
responseOverrides := map[string]*string{
|
||||
"Cache-Control": utils.GetQueryParam(ctx, "response-cache-control"),
|
||||
"Content-Disposition": utils.GetQueryParam(ctx, "response-content-disposition"),
|
||||
"Content-Encoding": utils.GetQueryParam(ctx, "response-content-encoding"),
|
||||
"Content-Language": utils.GetQueryParam(ctx, "response-content-language"),
|
||||
"Content-Type": utils.GetQueryParam(ctx, "response-content-type"),
|
||||
"Expires": utils.GetQueryParam(ctx, "response-expires"),
|
||||
}
|
||||
|
||||
// Check if any response override parameters are present
|
||||
hasResponseOverrides := false
|
||||
for _, override := range responseOverrides {
|
||||
if override != nil {
|
||||
hasResponseOverrides = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucketRequest := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
utils.ContextKeySkipResBodyLog.Set(ctx, true)
|
||||
|
||||
// Validate that response override parameters are not used with anonymous requests
|
||||
if hasResponseOverrides && isPublicBucketRequest {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders)
|
||||
}
|
||||
|
||||
action := auth.GetObjectAction
|
||||
if ctx.Request().URI().QueryArgs().Has("versionId") {
|
||||
action = auth.GetObjectVersionAction
|
||||
}
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: action,
|
||||
IsPublicRequest: isPublicBucketRequest,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var partNumber *int32
|
||||
if ctx.Request().URI().QueryArgs().Has("partNumber") {
|
||||
if partNumberQuery < minPartNumber || partNumberQuery > maxPartNumber {
|
||||
debuglogger.Logf("invalid part number: %d", partNumberQuery)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidPartNumber)
|
||||
}
|
||||
|
||||
partNumber = &partNumberQuery
|
||||
}
|
||||
|
||||
// validate the checksum mode
|
||||
if checksumMode != "" && checksumMode != types.ChecksumModeEnabled {
|
||||
debuglogger.Logf("invalid x-amz-checksum-mode header value: %v", checksumMode)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetInvalidChecksumHeaderErr("x-amz-checksum-mode")
|
||||
}
|
||||
|
||||
conditionalHeaders := utils.ParsePreconditionHeaders(ctx)
|
||||
|
||||
res, err := c.be.GetObject(ctx.Context(), &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Range: &acceptRange,
|
||||
IfMatch: conditionalHeaders.IfMatch,
|
||||
IfNoneMatch: conditionalHeaders.IfNoneMatch,
|
||||
IfModifiedSince: conditionalHeaders.IfModSince,
|
||||
IfUnmodifiedSince: conditionalHeaders.IfUnmodeSince,
|
||||
VersionId: &versionId,
|
||||
ChecksumMode: checksumMode,
|
||||
PartNumber: partNumber,
|
||||
})
|
||||
if err != nil {
|
||||
var headers map[string]*string
|
||||
if res != nil {
|
||||
headers = map[string]*string{
|
||||
"x-amz-delete-marker": utils.GetStringPtr("true"),
|
||||
"Last-Modified": utils.FormatDatePtrToString(res.LastModified, timefmt),
|
||||
}
|
||||
}
|
||||
return &Response{
|
||||
Headers: headers,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// Set x-amz-meta-... headers
|
||||
utils.SetMetaHeaders(ctx, res.Metadata)
|
||||
|
||||
status := http.StatusOK
|
||||
if acceptRange != "" {
|
||||
status = http.StatusPartialContent
|
||||
}
|
||||
|
||||
if res.Body != nil {
|
||||
// -1 will stream response body until EOF if content length not set
|
||||
contentLen := -1
|
||||
if res.ContentLength != nil {
|
||||
if *res.ContentLength > int64(math.MaxInt) {
|
||||
debuglogger.Logf("content length %v int overflow",
|
||||
*res.ContentLength)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
ContentLength: utils.GetInt64(res.ContentLength),
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: status,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidRange)
|
||||
}
|
||||
contentLen = int(*res.ContentLength)
|
||||
}
|
||||
utils.StreamResponseBody(ctx, res.Body, contentLen)
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Headers: map[string]*string{
|
||||
"ETag": res.ETag,
|
||||
"x-amz-restore": res.Restore,
|
||||
"accept-ranges": res.AcceptRanges,
|
||||
"Content-Range": res.ContentRange,
|
||||
"Content-Disposition": utils.ApplyOverride(res.ContentDisposition, responseOverrides["Content-Disposition"]),
|
||||
"Content-Encoding": utils.ApplyOverride(res.ContentEncoding, responseOverrides["Content-Encoding"]),
|
||||
"Content-Language": utils.ApplyOverride(res.ContentLanguage, responseOverrides["Content-Language"]),
|
||||
"Cache-Control": utils.ApplyOverride(res.CacheControl, responseOverrides["Cache-Control"]),
|
||||
"Expires": utils.ApplyOverride(res.ExpiresString, responseOverrides["Expires"]),
|
||||
"x-amz-checksum-crc32": res.ChecksumCRC32,
|
||||
"x-amz-checksum-crc64nvme": res.ChecksumCRC64NVME,
|
||||
"x-amz-checksum-crc32c": res.ChecksumCRC32C,
|
||||
"x-amz-checksum-sha1": res.ChecksumSHA1,
|
||||
"x-amz-checksum-sha256": res.ChecksumSHA256,
|
||||
"Content-Type": utils.ApplyOverride(res.ContentType, responseOverrides["Content-Type"]),
|
||||
"x-amz-version-id": res.VersionId,
|
||||
"Content-Length": utils.ConvertPtrToStringPtr(res.ContentLength),
|
||||
"x-amz-mp-parts-count": utils.ConvertPtrToStringPtr(res.PartsCount),
|
||||
"x-amz-tagging-count": utils.ConvertPtrToStringPtr(res.TagCount),
|
||||
"x-amz-object-lock-mode": utils.ConvertToStringPtr(res.ObjectLockMode),
|
||||
"x-amz-object-lock-legal-hold": utils.ConvertToStringPtr(res.ObjectLockLegalHoldStatus),
|
||||
"x-amz-storage-class": utils.ConvertToStringPtr(res.StorageClass),
|
||||
"x-amz-checksum-type": utils.ConvertToStringPtr(res.ChecksumType),
|
||||
"x-amz-object-lock-retain-until-date": utils.FormatDatePtrToString(res.ObjectLockRetainUntilDate, time.RFC3339),
|
||||
"Last-Modified": utils.FormatDatePtrToString(res.LastModified, timefmt),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
ContentLength: utils.GetInt64(res.ContentLength),
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: status,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
835
s3api/controllers/object-get_test.go
Normal file
835
s3api/controllers/object-get_test.go
Normal file
@@ -0,0 +1,835 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func TestS3ApiController_GetObjectTagging(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: map[string]string{},
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: map[string]string{
|
||||
"key": "val",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.Tagging{
|
||||
TagSet: s3response.TagSet{
|
||||
Tags: []s3response.Tag{
|
||||
{Key: "key", Value: "val"},
|
||||
},
|
||||
},
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
GetObjectTaggingFunc: func(contextMoqParam context.Context, bucket, object string) (map[string]string, error) {
|
||||
return tt.input.beRes.(map[string]string), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.GetObjectTagging,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_GetObjectRetention(t *testing.T) {
|
||||
retBytes, err := json.Marshal(types.ObjectLockRetention{
|
||||
Mode: types.ObjectLockRetentionModeCompliance,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
var retention *types.ObjectLockRetention
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: []byte{},
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid data from backend",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: []byte{},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: retention,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: fmt.Errorf("parse object lock retention: "),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: retBytes,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: &types.ObjectLockRetention{
|
||||
Mode: types.ObjectLockRetentionModeCompliance,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
GetObjectRetentionFunc: func(contextMoqParam context.Context, bucket, object, versionId string) ([]byte, error) {
|
||||
return tt.input.beRes.([]byte), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.GetObjectRetention,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_GetObjectLegalHold(t *testing.T) {
|
||||
var legalHold *bool
|
||||
var emptyLegalHold *s3response.GetObjectLegalHoldResult
|
||||
status := true
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: legalHold,
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: emptyLegalHold,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: &status,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: &s3response.GetObjectLegalHoldResult{
|
||||
Status: types.ObjectLockLegalHoldStatusOn,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
GetObjectLegalHoldFunc: func(contextMoqParam context.Context, bucket, object, versionId string) (*bool, error) {
|
||||
return tt.input.beRes.(*bool), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.GetObjectLegalHold,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_GetObjectAcl(t *testing.T) {
|
||||
var emptyRes *s3.GetObjectAclOutput
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: emptyRes,
|
||||
beErr: s3err.GetAPIError(s3err.ErrNotImplemented),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: emptyRes,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNotImplemented),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: &s3.GetObjectAclOutput{
|
||||
Owner: &types.Owner{
|
||||
ID: utils.GetStringPtr("something"),
|
||||
},
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: &s3.GetObjectAclOutput{
|
||||
Owner: &types.Owner{
|
||||
ID: utils.GetStringPtr("something"),
|
||||
},
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
GetObjectAclFunc: func(contextMoqParam context.Context, getObjectAclInput *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) {
|
||||
return tt.input.beRes.(*s3.GetObjectAclOutput), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.GetObjectAcl,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_ListParts(t *testing.T) {
|
||||
listPartsResult := s3response.ListPartsResult{
|
||||
Bucket: "my-bucket",
|
||||
Key: "obj",
|
||||
IsTruncated: false,
|
||||
Parts: []s3response.Part{
|
||||
{ETag: "ETag"},
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid part number marker",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
queries: map[string]string{
|
||||
"part-number-marker": "-1",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidPartNumberMarker),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid max parts",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
queries: map[string]string{
|
||||
"max-parts": "-1",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidMaxParts),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: s3response.ListPartsResult{},
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.ListPartsResult{},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: listPartsResult,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: listPartsResult,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
ListPartsFunc: func(contextMoqParam context.Context, listPartsInput *s3.ListPartsInput) (s3response.ListPartsResult, error) {
|
||||
return tt.input.beRes.(s3response.ListPartsResult), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.ListParts,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
queries: tt.input.queries,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_GetObjectAttributes(t *testing.T) {
|
||||
delMarker, lastModTime, etag := true, time.Now(), "ETag"
|
||||
timeFormatted := lastModTime.UTC().Format(iso8601TimeFormatExtended)
|
||||
|
||||
validRes := s3response.GetObjectAttributesResponse{
|
||||
DeleteMarker: &delMarker,
|
||||
LastModified: &lastModTime,
|
||||
VersionId: utils.GetStringPtr("versionId"),
|
||||
ETag: &etag,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid max parts",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Max-Parts": "-1",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidMaxParts),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid object attributes",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Object-Attributes": "invalid_attribute",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidObjectAttributes),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: validRes,
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
headers: map[string]string{
|
||||
"X-Amz-Object-Attributes": "ETag",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"x-amz-version-id": utils.GetStringPtr("versionId"),
|
||||
"x-amz-delete-marker": utils.GetStringPtr("true"),
|
||||
},
|
||||
Data: nil,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: validRes,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Object-Attributes": "ETag",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"x-amz-version-id": utils.GetStringPtr("versionId"),
|
||||
"x-amz-delete-marker": utils.GetStringPtr("true"),
|
||||
"Last-Modified": &timeFormatted,
|
||||
},
|
||||
Data: s3response.GetObjectAttributesResponse{
|
||||
ETag: &etag,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
GetObjectAttributesFunc: func(contextMoqParam context.Context, getObjectAttributesInput *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
|
||||
return tt.input.beRes.(s3response.GetObjectAttributesResponse), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.GetObjectAttributes,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
headers: tt.input.headers,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_GetObject(t *testing.T) {
|
||||
tm := time.Now()
|
||||
cLength := int64(11)
|
||||
rdr := io.NopCloser(strings.NewReader("hello world"))
|
||||
delMarker := true
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid checksum mode",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"x-amz-checksum-mode": "invalid_checksum_mode",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetInvalidChecksumHeaderErr("x-amz-checksum-mode"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid part number",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
queries: map[string]string{
|
||||
"partNumber": "-2",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidPartNumber),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidAccessKeyID),
|
||||
beRes: &s3.GetObjectOutput{
|
||||
DeleteMarker: &delMarker,
|
||||
LastModified: &tm,
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"x-amz-delete-marker": utils.GetStringPtr("true"),
|
||||
"Last-Modified": utils.GetStringPtr(tm.UTC().Format(timefmt)),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidAccessKeyID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
headers: map[string]string{
|
||||
"Range": "100-200",
|
||||
},
|
||||
queries: map[string]string{
|
||||
"versionId": "versionId",
|
||||
},
|
||||
locals: defaultLocals,
|
||||
beRes: &s3.GetObjectOutput{
|
||||
ETag: utils.GetStringPtr("ETag"),
|
||||
ContentType: utils.GetStringPtr("application/xml"),
|
||||
ContentLength: &cLength,
|
||||
Body: rdr,
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"ETag": utils.GetStringPtr("ETag"),
|
||||
"x-amz-restore": nil,
|
||||
"accept-ranges": nil,
|
||||
"Content-Range": nil,
|
||||
"Content-Disposition": nil,
|
||||
"Content-Encoding": nil,
|
||||
"Content-Language": nil,
|
||||
"Cache-Control": nil,
|
||||
"Expires": nil,
|
||||
"x-amz-checksum-crc32": nil,
|
||||
"x-amz-checksum-crc64nvme": nil,
|
||||
"x-amz-checksum-crc32c": nil,
|
||||
"x-amz-checksum-sha1": nil,
|
||||
"x-amz-checksum-sha256": nil,
|
||||
"x-amz-version-id": nil,
|
||||
"x-amz-mp-parts-count": nil,
|
||||
"x-amz-object-lock-mode": nil,
|
||||
"x-amz-object-lock-legal-hold": nil,
|
||||
"x-amz-storage-class": nil,
|
||||
"x-amz-checksum-type": nil,
|
||||
"x-amz-object-lock-retain-until-date": nil,
|
||||
"Last-Modified": nil,
|
||||
"x-amz-tagging-count": nil,
|
||||
"Content-Type": utils.GetStringPtr("application/xml"),
|
||||
"Content-Length": utils.GetStringPtr("11"),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
Status: http.StatusPartialContent,
|
||||
ContentLength: cLength,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
GetObjectFunc: func(contextMoqParam context.Context, getObjectInput *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
return tt.input.beRes.(*s3.GetObjectOutput), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.GetObject,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
headers: tt.input.headers,
|
||||
queries: tt.input.queries,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
158
s3api/controllers/object-head.go
Normal file
158
s3api/controllers/object-head.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
// url values
|
||||
bucket := ctx.Params("bucket")
|
||||
partNumberQuery := int32(ctx.QueryInt("partNumber", -1))
|
||||
versionId := ctx.Query("versionId")
|
||||
objRange := ctx.Get("Range")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
|
||||
action := auth.GetObjectAction
|
||||
if ctx.Request().URI().QueryArgs().Has("versionId") {
|
||||
action = auth.GetObjectVersionAction
|
||||
}
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: action,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var partNumber *int32
|
||||
if ctx.Request().URI().QueryArgs().Has("partNumber") {
|
||||
if partNumberQuery < minPartNumber || partNumberQuery > maxPartNumber {
|
||||
debuglogger.Logf("invalid part number: %d", partNumberQuery)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidPartNumber)
|
||||
}
|
||||
|
||||
partNumber = &partNumberQuery
|
||||
}
|
||||
|
||||
checksumMode := types.ChecksumMode(strings.ToUpper(ctx.Get("x-amz-checksum-mode")))
|
||||
if checksumMode != "" && checksumMode != types.ChecksumModeEnabled {
|
||||
debuglogger.Logf("invalid x-amz-checksum-mode header value: %v", checksumMode)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetInvalidChecksumHeaderErr("x-amz-checksum-mode")
|
||||
}
|
||||
|
||||
conditionalHeaders := utils.ParsePreconditionHeaders(ctx)
|
||||
|
||||
res, err := c.be.HeadObject(ctx.Context(),
|
||||
&s3.HeadObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
PartNumber: partNumber,
|
||||
VersionId: &versionId,
|
||||
ChecksumMode: checksumMode,
|
||||
Range: &objRange,
|
||||
IfMatch: conditionalHeaders.IfMatch,
|
||||
IfNoneMatch: conditionalHeaders.IfNoneMatch,
|
||||
IfModifiedSince: conditionalHeaders.IfModSince,
|
||||
IfUnmodifiedSince: conditionalHeaders.IfUnmodeSince,
|
||||
})
|
||||
if err != nil {
|
||||
var headers map[string]*string
|
||||
if res != nil {
|
||||
headers = map[string]*string{
|
||||
"x-amz-delete-marker": utils.GetStringPtr("true"),
|
||||
"Last-Modified": utils.GetStringPtr(res.LastModified.UTC().Format(timefmt)),
|
||||
}
|
||||
}
|
||||
return &Response{
|
||||
Headers: headers,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// Set the metadata headers
|
||||
utils.SetMetaHeaders(ctx, res.Metadata)
|
||||
|
||||
return &Response{
|
||||
Headers: map[string]*string{
|
||||
"ETag": res.ETag,
|
||||
"x-amz-restore": res.Restore,
|
||||
"accept-ranges": res.AcceptRanges,
|
||||
"Content-Range": res.ContentRange,
|
||||
"Content-Disposition": res.ContentDisposition,
|
||||
"Content-Encoding": res.ContentEncoding,
|
||||
"Content-Language": res.ContentLanguage,
|
||||
"Cache-Control": res.CacheControl,
|
||||
"Expires": res.ExpiresString,
|
||||
"x-amz-checksum-crc32": res.ChecksumCRC32,
|
||||
"x-amz-checksum-crc64nvme": res.ChecksumCRC64NVME,
|
||||
"x-amz-checksum-crc32c": res.ChecksumCRC32C,
|
||||
"x-amz-checksum-sha1": res.ChecksumSHA1,
|
||||
"x-amz-checksum-sha256": res.ChecksumSHA256,
|
||||
"Content-Type": res.ContentType,
|
||||
"x-amz-version-id": res.VersionId,
|
||||
"Content-Length": utils.ConvertPtrToStringPtr(res.ContentLength),
|
||||
"x-amz-mp-parts-count": utils.ConvertPtrToStringPtr(res.PartsCount),
|
||||
"x-amz-object-lock-mode": utils.ConvertToStringPtr(res.ObjectLockMode),
|
||||
"x-amz-object-lock-legal-hold": utils.ConvertToStringPtr(res.ObjectLockLegalHoldStatus),
|
||||
"x-amz-storage-class": utils.ConvertToStringPtr(res.StorageClass),
|
||||
"x-amz-checksum-type": utils.ConvertToStringPtr(res.ChecksumType),
|
||||
"x-amz-object-lock-retain-until-date": utils.FormatDatePtrToString(res.ObjectLockRetainUntilDate, time.RFC3339),
|
||||
"Last-Modified": utils.FormatDatePtrToString(res.LastModified, timefmt),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
187
s3api/controllers/object-head_test.go
Normal file
187
s3api/controllers/object-head_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func TestS3ApiController_HeadObject(t *testing.T) {
|
||||
tm := time.Now()
|
||||
cLength := int64(100)
|
||||
|
||||
failingBeRes := &s3.HeadObjectOutput{
|
||||
LastModified: &tm,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid part number",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
queries: map[string]string{
|
||||
"partNumber": "-4",
|
||||
"versionId": "id",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidPartNumber),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid checksum mode",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"x-amz-checksum-mode": "invalid_checksum_mode",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetInvalidChecksumHeaderErr("x-amz-checksum-mode"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrInvalidAccessKeyID),
|
||||
beRes: failingBeRes,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"x-amz-delete-marker": utils.GetStringPtr("true"),
|
||||
"Last-Modified": utils.GetStringPtr(tm.UTC().Format(timefmt)),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidAccessKeyID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
queries: map[string]string{
|
||||
"partNumber": "4",
|
||||
},
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"x-amz-checksum-mode": "enabled",
|
||||
},
|
||||
beRes: &s3.HeadObjectOutput{
|
||||
ETag: utils.GetStringPtr("ETag"),
|
||||
ContentType: utils.GetStringPtr("application/xml"),
|
||||
ContentLength: &cLength,
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"ETag": utils.GetStringPtr("ETag"),
|
||||
"x-amz-restore": nil,
|
||||
"accept-ranges": nil,
|
||||
"Content-Range": nil,
|
||||
"Content-Disposition": nil,
|
||||
"Content-Encoding": nil,
|
||||
"Content-Language": nil,
|
||||
"Cache-Control": nil,
|
||||
"Expires": nil,
|
||||
"x-amz-checksum-crc32": nil,
|
||||
"x-amz-checksum-crc64nvme": nil,
|
||||
"x-amz-checksum-crc32c": nil,
|
||||
"x-amz-checksum-sha1": nil,
|
||||
"x-amz-checksum-sha256": nil,
|
||||
"x-amz-version-id": nil,
|
||||
"x-amz-mp-parts-count": nil,
|
||||
"x-amz-object-lock-mode": nil,
|
||||
"x-amz-object-lock-legal-hold": nil,
|
||||
"x-amz-storage-class": nil,
|
||||
"x-amz-checksum-type": nil,
|
||||
"x-amz-object-lock-retain-until-date": nil,
|
||||
"Last-Modified": nil,
|
||||
"Content-Type": utils.GetStringPtr("application/xml"),
|
||||
"Content-Length": utils.GetStringPtr("100"),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
HeadObjectFunc: func(contextMoqParam context.Context, headObjectInput *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
|
||||
return tt.input.beRes.(*s3.HeadObjectOutput), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.HeadObject,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
queries: tt.input.queries,
|
||||
headers: tt.input.headers,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
358
s3api/controllers/object-post.go
Normal file
358
s3api/controllers/object-post.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func (c S3ApiController) RestoreObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.RestoreObjectAction,
|
||||
IsPublicRequest: isBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var restoreRequest types.RestoreRequest
|
||||
if err := xml.Unmarshal(ctx.Body(), &restoreRequest); err != nil {
|
||||
debuglogger.Logf("failed to parse the request body: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
err = c.be.RestoreObject(ctx.Context(), &s3.RestoreObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
RestoreRequest: &restoreRequest,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
EventName: s3event.EventObjectRestoreCompleted,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) SelectObjectContent(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.GetObjectAction,
|
||||
IsPublicRequest: isBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var payload s3response.SelectObjectContentPayload
|
||||
err = xml.Unmarshal(ctx.Body(), &payload)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error unmarshalling select object content: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
sw := c.be.SelectObjectContent(ctx.Context(),
|
||||
&s3.SelectObjectContentInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Expression: payload.Expression,
|
||||
ExpressionType: payload.ExpressionType,
|
||||
InputSerialization: payload.InputSerialization,
|
||||
OutputSerialization: payload.OutputSerialization,
|
||||
RequestProgress: payload.RequestProgress,
|
||||
ScanRange: payload.ScanRange,
|
||||
})
|
||||
|
||||
ctx.Context().SetBodyStreamWriter(sw)
|
||||
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
contentType := ctx.Get("Content-Type")
|
||||
contentDisposition := ctx.Get("Content-Disposition")
|
||||
contentLanguage := ctx.Get("Content-Language")
|
||||
cacheControl := ctx.Get("Cache-Control")
|
||||
contentEncoding := ctx.Get("Content-Encoding")
|
||||
tagging := ctx.Get("X-Amz-Tagging")
|
||||
expires := ctx.Get("Expires")
|
||||
metadata := utils.GetUserMetaData(&ctx.Request().Header)
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectAction,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
objLockState, err := utils.ParsObjectLockHdrs(ctx)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
checksumAlgorithm, checksumType, err := utils.ParseCreateMpChecksumHeaders(ctx)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
res, err := c.be.CreateMultipartUpload(ctx.Context(),
|
||||
s3response.CreateMultipartUploadInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Tagging: &tagging,
|
||||
ContentType: &contentType,
|
||||
ContentEncoding: &contentEncoding,
|
||||
ContentDisposition: &contentDisposition,
|
||||
ContentLanguage: &contentLanguage,
|
||||
CacheControl: &cacheControl,
|
||||
Expires: &expires,
|
||||
ObjectLockRetainUntilDate: &objLockState.RetainUntilDate,
|
||||
ObjectLockMode: objLockState.ObjectLockMode,
|
||||
ObjectLockLegalHoldStatus: objLockState.LegalHoldStatus,
|
||||
Metadata: metadata,
|
||||
ChecksumAlgorithm: checksumAlgorithm,
|
||||
ChecksumType: checksumType,
|
||||
})
|
||||
var headers map[string]*string
|
||||
if err == nil {
|
||||
headers = map[string]*string{
|
||||
"x-amz-checksum-algorithm": utils.ConvertToStringPtr(checksumAlgorithm),
|
||||
"x-amz-checksum-type": utils.ConvertToStringPtr(checksumType),
|
||||
}
|
||||
}
|
||||
return &Response{
|
||||
Headers: headers,
|
||||
Data: res,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) CompleteMultipartUpload(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
uploadId := ctx.Query("uploadId")
|
||||
mpuObjSizeHdr := ctx.Get("X-Amz-Mp-Object-Size")
|
||||
checksumType := types.ChecksumType(strings.ToUpper(ctx.Get("x-amz-checksum-type")))
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectAction,
|
||||
IsPublicRequest: isBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var body s3response.CompleteMultipartUploadRequestBody
|
||||
err = xml.Unmarshal(ctx.Body(), &body)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error unmarshalling complete multipart upload: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
if len(body.Parts) == 0 {
|
||||
debuglogger.Logf("empty parts provided for complete multipart upload")
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrEmptyParts)
|
||||
}
|
||||
|
||||
var mpuObjectSize *int64
|
||||
if mpuObjSizeHdr != "" {
|
||||
val, err := strconv.ParseInt(mpuObjSizeHdr, 10, 64)
|
||||
if err != nil {
|
||||
debuglogger.Logf("invalid value for 'x-amz-mp-object-size' header: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetInvalidMpObjectSizeErr(mpuObjSizeHdr)
|
||||
}
|
||||
|
||||
if val < 0 {
|
||||
debuglogger.Logf("value for 'x-amz-mp-object-size' header is less than 0: %v", val)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetNegatvieMpObjectSizeErr(val)
|
||||
}
|
||||
|
||||
mpuObjectSize = &val
|
||||
}
|
||||
|
||||
checksums, err := utils.ParseChecksumHeaders(ctx)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = utils.IsChecksumTypeValid(checksumType)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
ifMatch, ifNoneMatch := utils.ParsePreconditionMatchHeaders(ctx)
|
||||
|
||||
res, versid, err := c.be.CompleteMultipartUpload(ctx.Context(),
|
||||
&s3.CompleteMultipartUploadInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
UploadId: &uploadId,
|
||||
MultipartUpload: &types.CompletedMultipartUpload{
|
||||
Parts: body.Parts,
|
||||
},
|
||||
MpuObjectSize: mpuObjectSize,
|
||||
ChecksumCRC32: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32]),
|
||||
ChecksumCRC32C: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32c]),
|
||||
ChecksumSHA1: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha1]),
|
||||
ChecksumSHA256: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha256]),
|
||||
ChecksumCRC64NVME: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc64nvme]),
|
||||
ChecksumType: checksumType,
|
||||
IfMatch: ifMatch,
|
||||
IfNoneMatch: ifNoneMatch,
|
||||
})
|
||||
return &Response{
|
||||
Data: res,
|
||||
Headers: map[string]*string{
|
||||
"x-amz-version-id": &versid,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
ObjectETag: res.ETag,
|
||||
EventName: s3event.EventCompleteMultipartUpload,
|
||||
VersionId: &versid,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
563
s3api/controllers/object-post_test.go
Normal file
563
s3api/controllers/object-post_test.go
Normal file
@@ -0,0 +1,563 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func TestS3ApiController_RestoreObject(t *testing.T) {
|
||||
validRestoreBody, err := xml.Marshal(types.RestoreRequest{
|
||||
Description: utils.GetStringPtr("description"),
|
||||
Type: types.RestoreRequestTypeSelect,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid request body",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: []byte("invalid_body"),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
body: validRestoreBody,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
EventName: s3event.EventObjectRestoreCompleted,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validRestoreBody,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
EventName: s3event.EventObjectRestoreCompleted,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
RestoreObjectFunc: func(contextMoqParam context.Context, restoreObjectInput *s3.RestoreObjectInput) error {
|
||||
return tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.RestoreObject,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_SelectObjectContent(t *testing.T) {
|
||||
validSelectBody, err := xml.Marshal(s3response.SelectObjectContentPayload{
|
||||
Expression: utils.GetStringPtr("expression"),
|
||||
ExpressionType: types.ExpressionTypeSql,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid request body",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: []byte("invalid_body"),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validSelectBody,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
SelectObjectContentFunc: func(ctx context.Context, input *s3.SelectObjectContentInput) func(w *bufio.Writer) {
|
||||
return func(w *bufio.Writer) {}
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.SelectObjectContent,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_CreateMultipartUpload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid object lock headers",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Object-Lock-Mode": string(types.ObjectLockModeGovernance),
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrObjectLockInvalidHeaders),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid checksum headers",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Checksum-Algorithm": "invalid_checksum_algo",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrInvalidChecksumAlgorithm),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
beRes: s3response.InitiateMultipartUploadResult{},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.InitiateMultipartUploadResult{},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
beRes: s3response.InitiateMultipartUploadResult{},
|
||||
headers: map[string]string{
|
||||
"x-amz-checksum-algorithm": string(types.ChecksumAlgorithmCrc32),
|
||||
"x-amz-checksum-type": string(types.ChecksumTypeComposite),
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.InitiateMultipartUploadResult{},
|
||||
Headers: map[string]*string{
|
||||
"x-amz-checksum-algorithm": utils.ConvertToStringPtr(types.ChecksumAlgorithmCrc32),
|
||||
"x-amz-checksum-type": utils.ConvertToStringPtr(types.ChecksumTypeComposite),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
CreateMultipartUploadFunc: func(contextMoqParam context.Context, createMultipartUploadInput s3response.CreateMultipartUploadInput) (s3response.InitiateMultipartUploadResult, error) {
|
||||
return tt.input.beRes.(s3response.InitiateMultipartUploadResult), tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.CreateMultipartUpload,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
headers: tt.input.headers,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ApiController_CompleteMultipartUpload(t *testing.T) {
|
||||
emptyMpPartsBody, err := xml.Marshal(s3response.CompleteMultipartUploadRequestBody{
|
||||
Parts: []types.CompletedPart{},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
pn := int32(1)
|
||||
|
||||
validMpBody, err := xml.Marshal(s3response.CompleteMultipartUploadRequestBody{
|
||||
Parts: []types.CompletedPart{
|
||||
{
|
||||
PartNumber: &pn,
|
||||
ETag: utils.GetStringPtr("ETag"),
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
versionId, ETag := "versionId", "mock-ETag"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "verify access fails",
|
||||
input: testInput{
|
||||
locals: accessDeniedLocals,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid request body",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: []byte("invalid_body"),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrMalformedXML),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "request body empty mp parts",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: emptyMpPartsBody,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrEmptyParts),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid mp parts header string",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validMpBody,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Mp-Object-Size": "invalid_mp_object_size",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetInvalidMpObjectSizeErr("invalid_mp_object_size"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negative mp parts header value",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validMpBody,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Mp-Object-Size": "-4",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetNegatvieMpObjectSizeErr(-4),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid checksum headers",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validMpBody,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Checksum-Crc32": "invalid_checksum",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetInvalidChecksumHeaderErr("x-amz-checksum-crc32"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid checksum type",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validMpBody,
|
||||
headers: map[string]string{
|
||||
"X-Amz-Checksum-Type": "invalid_checksum_type",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetInvalidChecksumHeaderErr("x-amz-checksum-type"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backend returns error",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validMpBody,
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
beRes: s3response.CompleteMultipartUploadResult{},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.CompleteMultipartUploadResult{},
|
||||
Headers: map[string]*string{
|
||||
"x-amz-version-id": &versionId,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
EventName: s3event.EventCompleteMultipartUpload,
|
||||
VersionId: &versionId,
|
||||
ObjectETag: nil,
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
body: validMpBody,
|
||||
beRes: s3response.CompleteMultipartUploadResult{
|
||||
ETag: &ETag,
|
||||
},
|
||||
headers: map[string]string{
|
||||
"X-Amz-Mp-Object-Size": "3",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Data: s3response.CompleteMultipartUploadResult{
|
||||
ETag: &ETag,
|
||||
},
|
||||
Headers: map[string]*string{
|
||||
"x-amz-version-id": &versionId,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
EventName: s3event.EventCompleteMultipartUpload,
|
||||
VersionId: &versionId,
|
||||
ObjectETag: &ETag,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
CompleteMultipartUploadFunc: func(contextMoqParam context.Context, completeMultipartUploadInput *s3.CompleteMultipartUploadInput) (s3response.CompleteMultipartUploadResult, string, error) {
|
||||
return tt.input.beRes.(s3response.CompleteMultipartUploadResult), versionId, tt.input.beErr
|
||||
},
|
||||
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.CompleteMultipartUpload,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
body: tt.input.body,
|
||||
headers: tt.input.headers,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
716
s3api/controllers/object-put.go
Normal file
716
s3api/controllers/object-put.go
Normal file
@@ -0,0 +1,716 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
func (c S3ApiController) PutObjectTagging(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectTaggingAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
tagging, err := utils.ParseTagging(ctx.Body(), utils.TagLimitObject)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.PutObjectTagging(ctx.Context(), bucket, key, tagging)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
EventName: s3event.EventObjectTaggingPut,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutObjectRetention(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
versionId := ctx.Query("versionId")
|
||||
bypass := strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
if err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectRetentionAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
}); err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
if bypass {
|
||||
policy, err := c.be.GetBucketPolicy(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
bypass = false
|
||||
} else {
|
||||
if err := auth.VerifyBucketPolicy(policy, acct.Access, bucket, key, auth.BypassGovernanceRetentionAction); err != nil {
|
||||
bypass = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retention, err := auth.ParseObjectLockRetentionInput(ctx.Body())
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to parse object lock configuration input: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.PutObjectRetention(ctx.Context(), bucket, key, versionId, bypass, retention)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutObjectLegalHold(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
versionId := ctx.Query("versionId")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
if err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectLegalHoldAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
}); err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var legalHold types.ObjectLockLegalHold
|
||||
if err := xml.Unmarshal(ctx.Body(), &legalHold); err != nil {
|
||||
debuglogger.Logf("failed to parse request body: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
if legalHold.Status != types.ObjectLockLegalHoldStatusOff && legalHold.Status != types.ObjectLockLegalHoldStatusOn {
|
||||
debuglogger.Logf("invalid legal hold status: %v", legalHold.Status)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
err := c.be.PutObjectLegalHold(ctx.Context(), bucket, key, versionId, legalHold.Status == types.ObjectLockLegalHoldStatusOn)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) UploadPart(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
partNumber := int32(ctx.QueryInt("partNumber", -1))
|
||||
uploadId := ctx.Query("uploadId")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
contentLengthStr := ctx.Get("Content-Length")
|
||||
if contentLengthStr == "" {
|
||||
contentLengthStr = "0"
|
||||
}
|
||||
// Use decoded content length if available because the
|
||||
// middleware will decode the chunked transfer encoding
|
||||
decodedLength := ctx.Get("X-Amz-Decoded-Content-Length")
|
||||
if decodedLength != "" {
|
||||
contentLengthStr = decodedLength
|
||||
}
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
if partNumber < minPartNumber || partNumber > maxPartNumber {
|
||||
debuglogger.Logf("invalid part number: %d", partNumber)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidPartNumber)
|
||||
}
|
||||
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error parsing content length %q: %v", contentLengthStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
algorithm, checksums, err := utils.ParseChecksumHeadersAndSdkAlgo(ctx)
|
||||
if err != nil {
|
||||
debuglogger.Logf("err parsing checksum headers: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
bodyi := utils.ContextKeyBodyReader.Get(ctx)
|
||||
if bodyi != nil {
|
||||
body = bodyi.(io.Reader)
|
||||
} else {
|
||||
body = bytes.NewReader([]byte{})
|
||||
}
|
||||
|
||||
res, err := c.be.UploadPart(ctx.Context(),
|
||||
&s3.UploadPartInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
UploadId: &uploadId,
|
||||
PartNumber: &partNumber,
|
||||
ContentLength: &contentLength,
|
||||
Body: body,
|
||||
ChecksumAlgorithm: algorithm,
|
||||
ChecksumCRC32: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32]),
|
||||
ChecksumCRC32C: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32c]),
|
||||
ChecksumSHA1: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha1]),
|
||||
ChecksumSHA256: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha256]),
|
||||
ChecksumCRC64NVME: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc64nvme]),
|
||||
})
|
||||
var headers map[string]*string
|
||||
if err == nil {
|
||||
headers = map[string]*string{
|
||||
"ETag": res.ETag,
|
||||
"x-amz-checksum-crc32": res.ChecksumCRC32,
|
||||
"x-amz-checksum-crc32c": res.ChecksumCRC32C,
|
||||
"x-amz-checksum-crc64nvme": res.ChecksumCRC64NVME,
|
||||
"x-amz-checksum-sha1": res.ChecksumSHA1,
|
||||
"x-amz-checksum-sha256": res.ChecksumSHA256,
|
||||
}
|
||||
}
|
||||
return &Response{
|
||||
Headers: headers,
|
||||
MetaOpts: &MetaOptions{
|
||||
ContentLength: contentLength,
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
|
||||
}
|
||||
|
||||
func (c S3ApiController) UploadPartCopy(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
copySource := strings.TrimPrefix(ctx.Get("X-Amz-Copy-Source"), "/")
|
||||
copySrcRange := ctx.Get("X-Amz-Copy-Source-Range")
|
||||
partNumber := int32(ctx.QueryInt("partNumber", -1))
|
||||
uploadId := ctx.Query("uploadId")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := utils.ValidateCopySource(copySource)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = auth.VerifyObjectCopyAccess(ctx.Context(), c.be, copySource,
|
||||
auth.AccessOptions{
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
if partNumber < minPartNumber || partNumber > maxPartNumber {
|
||||
debuglogger.Logf("invalid part number: %d", partNumber)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidPartNumber)
|
||||
}
|
||||
|
||||
preconditionHdrs := utils.ParsePreconditionHeaders(ctx, utils.WithCopySource())
|
||||
|
||||
resp, err := c.be.UploadPartCopy(ctx.Context(),
|
||||
&s3.UploadPartCopyInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
CopySource: ©Source,
|
||||
PartNumber: &partNumber,
|
||||
UploadId: &uploadId,
|
||||
CopySourceRange: ©SrcRange,
|
||||
CopySourceIfMatch: preconditionHdrs.IfMatch,
|
||||
CopySourceIfNoneMatch: preconditionHdrs.IfNoneMatch,
|
||||
CopySourceIfModifiedSince: preconditionHdrs.IfModSince,
|
||||
CopySourceIfUnmodifiedSince: preconditionHdrs.IfUnmodeSince,
|
||||
})
|
||||
var headers map[string]*string
|
||||
if err == nil && resp.CopySourceVersionId != "" {
|
||||
headers = map[string]*string{
|
||||
"x-amz-copy-source-version-id": &resp.CopySourceVersionId,
|
||||
}
|
||||
}
|
||||
return &Response{
|
||||
Headers: headers,
|
||||
Data: resp,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutObjectAcl(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
acl := ctx.Get("X-Amz-Acl")
|
||||
grantFullControl := ctx.Get("X-Amz-Grant-Full-Control")
|
||||
grantRead := ctx.Get("X-Amz-Grant-Read")
|
||||
grantReadACP := ctx.Get("X-Amz-Grant-Read-Acp")
|
||||
grantWrite := ctx.Get("X-Amz-Grant-Write")
|
||||
grantWriteACP := ctx.Get("X-Amz-Grant-Write-Acp")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectAclAction,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.PutObjectAcl(ctx.Context(), &s3.PutObjectAclInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
GrantFullControl: &grantFullControl,
|
||||
GrantRead: &grantRead,
|
||||
GrantWrite: &grantWrite,
|
||||
ACL: types.ObjectCannedACL(acl),
|
||||
GrantReadACP: &grantReadACP,
|
||||
GrantWriteACP: &grantWriteACP,
|
||||
})
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
EventName: s3event.EventObjectAclPut,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
copySource := strings.TrimPrefix(ctx.Get("X-Amz-Copy-Source"), "/")
|
||||
metaDirective := types.MetadataDirective(ctx.Get("X-Amz-Metadata-Directive", string(types.MetadataDirectiveCopy)))
|
||||
taggingDirective := types.TaggingDirective(ctx.Get("X-Amz-Tagging-Directive", string(types.TaggingDirectiveCopy)))
|
||||
contentType := ctx.Get("Content-Type")
|
||||
contentEncoding := ctx.Get("Content-Encoding")
|
||||
contentDisposition := ctx.Get("Content-Disposition")
|
||||
contentLanguage := ctx.Get("Content-Language")
|
||||
cacheControl := ctx.Get("Cache-Control")
|
||||
expires := ctx.Get("Expires")
|
||||
tagging := ctx.Get("x-amz-tagging")
|
||||
storageClass := ctx.Get("X-Amz-Storage-Class")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := utils.ValidateCopySource(copySource)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = auth.VerifyObjectCopyAccess(ctx.Context(), c.be, copySource,
|
||||
auth.AccessOptions{
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectAction,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
metadata := utils.GetUserMetaData(&ctx.Request().Header)
|
||||
|
||||
if metaDirective != "" && metaDirective != types.MetadataDirectiveCopy && metaDirective != types.MetadataDirectiveReplace {
|
||||
debuglogger.Logf("invalid metadata directive: %v", metaDirective)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidMetadataDirective)
|
||||
}
|
||||
|
||||
if taggingDirective != "" && taggingDirective != types.TaggingDirectiveCopy && taggingDirective != types.TaggingDirectiveReplace {
|
||||
debuglogger.Logf("invalid tagging directive: %v", taggingDirective)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidTaggingDirective)
|
||||
}
|
||||
|
||||
checksumAlgorithm := types.ChecksumAlgorithm(ctx.Get("x-amz-checksum-algorithm"))
|
||||
err = utils.IsChecksumAlgorithmValid(checksumAlgorithm)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
objLock, err := utils.ParsObjectLockHdrs(ctx)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
preconditionHdrs := utils.ParsePreconditionHeaders(ctx, utils.WithCopySource())
|
||||
|
||||
res, err := c.be.CopyObject(ctx.Context(),
|
||||
s3response.CopyObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
ContentType: &contentType,
|
||||
ContentDisposition: &contentDisposition,
|
||||
ContentEncoding: &contentEncoding,
|
||||
ContentLanguage: &contentLanguage,
|
||||
CacheControl: &cacheControl,
|
||||
Expires: &expires,
|
||||
Tagging: &tagging,
|
||||
TaggingDirective: taggingDirective,
|
||||
CopySource: ©Source,
|
||||
CopySourceIfMatch: preconditionHdrs.IfMatch,
|
||||
CopySourceIfNoneMatch: preconditionHdrs.IfNoneMatch,
|
||||
CopySourceIfModifiedSince: preconditionHdrs.IfModSince,
|
||||
CopySourceIfUnmodifiedSince: preconditionHdrs.IfUnmodeSince,
|
||||
ExpectedBucketOwner: &acct.Access,
|
||||
Metadata: metadata,
|
||||
MetadataDirective: metaDirective,
|
||||
StorageClass: types.StorageClass(storageClass),
|
||||
ChecksumAlgorithm: checksumAlgorithm,
|
||||
ObjectLockRetainUntilDate: &objLock.RetainUntilDate,
|
||||
ObjectLockLegalHoldStatus: objLock.LegalHoldStatus,
|
||||
ObjectLockMode: objLock.ObjectLockMode,
|
||||
})
|
||||
|
||||
var etag *string
|
||||
if err == nil {
|
||||
etag = res.CopyObjectResult.ETag
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Headers: map[string]*string{
|
||||
"x-amz-version-id": res.VersionId,
|
||||
"x-amz-copy-source-version-id": res.CopySourceVersionId,
|
||||
},
|
||||
Data: res.CopyObjectResult,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
ObjectETag: etag,
|
||||
VersionId: res.VersionId,
|
||||
EventName: s3event.EventObjectCreatedCopy,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
|
||||
contentType := ctx.Get("Content-Type")
|
||||
contentEncoding := ctx.Get("Content-Encoding")
|
||||
contentDisposition := ctx.Get("Content-Disposition")
|
||||
contentLanguage := ctx.Get("Content-Language")
|
||||
cacheControl := ctx.Get("Cache-Control")
|
||||
expires := ctx.Get("Expires")
|
||||
tagging := ctx.Get("x-amz-tagging")
|
||||
// context locals
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
// Content Length
|
||||
contentLengthStr := ctx.Get("Content-Length")
|
||||
if contentLengthStr == "" {
|
||||
contentLengthStr = "0"
|
||||
}
|
||||
// Use decoded content length if available because the
|
||||
// middleware will decode the chunked transfer encoding
|
||||
decodedLength := ctx.Get("X-Amz-Decoded-Content-Length")
|
||||
if decodedLength != "" {
|
||||
contentLengthStr = decodedLength
|
||||
}
|
||||
|
||||
// load the meta headers
|
||||
metadata := utils.GetUserMetaData(&ctx.Request().Header)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Object: key,
|
||||
Action: auth.PutObjectAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = auth.CheckObjectAccess(ctx.Context(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, IsBucketPublic, c.be)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err != nil {
|
||||
debuglogger.Logf("error parsing content length %q: %v", contentLengthStr, err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
objLock, err := utils.ParsObjectLockHdrs(ctx)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
algorithm, checksums, err := utils.ParseChecksumHeadersAndSdkAlgo(ctx)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
bodyi := utils.ContextKeyBodyReader.Get(ctx)
|
||||
if bodyi != nil {
|
||||
body = bodyi.(io.Reader)
|
||||
} else {
|
||||
body = bytes.NewReader([]byte{})
|
||||
}
|
||||
|
||||
ifMatch, ifNoneMatch := utils.ParsePreconditionMatchHeaders(ctx)
|
||||
|
||||
res, err := c.be.PutObject(ctx.Context(),
|
||||
s3response.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
ContentLength: &contentLength,
|
||||
ContentType: &contentType,
|
||||
ContentEncoding: &contentEncoding,
|
||||
ContentDisposition: &contentDisposition,
|
||||
ContentLanguage: &contentLanguage,
|
||||
CacheControl: &cacheControl,
|
||||
Expires: &expires,
|
||||
Metadata: metadata,
|
||||
Body: body,
|
||||
Tagging: &tagging,
|
||||
ObjectLockRetainUntilDate: &objLock.RetainUntilDate,
|
||||
ObjectLockMode: objLock.ObjectLockMode,
|
||||
ObjectLockLegalHoldStatus: objLock.LegalHoldStatus,
|
||||
ChecksumAlgorithm: algorithm,
|
||||
ChecksumCRC32: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32]),
|
||||
ChecksumCRC32C: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32c]),
|
||||
ChecksumSHA1: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha1]),
|
||||
ChecksumSHA256: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha256]),
|
||||
ChecksumCRC64NVME: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc64nvme]),
|
||||
IfMatch: ifMatch,
|
||||
IfNoneMatch: ifNoneMatch,
|
||||
})
|
||||
return &Response{
|
||||
Headers: map[string]*string{
|
||||
"ETag": &res.ETag,
|
||||
"x-amz-checksum-crc32": res.ChecksumCRC32,
|
||||
"x-amz-checksum-crc32c": res.ChecksumCRC32C,
|
||||
"x-amz-checksum-crc64nvme": res.ChecksumCRC64NVME,
|
||||
"x-amz-checksum-sha1": res.ChecksumSHA1,
|
||||
"x-amz-checksum-sha256": res.ChecksumSHA256,
|
||||
"x-amz-checksum-type": utils.ConvertToStringPtr(res.ChecksumType),
|
||||
"x-amz-version-id": &res.VersionID,
|
||||
"x-amz-object-size": utils.ConvertPtrToStringPtr(res.Size),
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
ContentLength: contentLength,
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
ObjectETag: &res.ETag,
|
||||
ObjectSize: contentLength,
|
||||
EventName: s3event.EventObjectCreatedPut,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
1214
s3api/controllers/object-put_test.go
Normal file
1214
s3api/controllers/object-put_test.go
Normal file
File diff suppressed because it is too large
Load Diff
113
s3api/controllers/options.go
Normal file
113
s3api/controllers/options.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3api/middlewares"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func (s S3ApiController) CORSOptions(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
// get headers
|
||||
origin := ctx.Get("Origin")
|
||||
method := auth.CORSHTTPMethod(ctx.Get("Access-Control-Request-Method"))
|
||||
headers := ctx.Get("Access-Control-Request-Headers")
|
||||
|
||||
// Origin is required
|
||||
if origin == "" {
|
||||
debuglogger.Logf("origin is missing: %v", origin)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMissingCORSOrigin)
|
||||
}
|
||||
|
||||
// check if allowed method is valid
|
||||
if !method.IsValid() {
|
||||
debuglogger.Logf("invalid cors method: %s", method)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetInvalidCORSMethodErr(method.String())
|
||||
}
|
||||
|
||||
// parse and validate headers
|
||||
parsedHeaders, err := auth.ParseCORSHeaders(headers)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
cors, err := s.be.GetBucketCors(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to get bucket cors: %v", err)
|
||||
if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)) {
|
||||
err = s3err.GetAPIError(s3err.ErrCORSIsNotEnabled)
|
||||
debuglogger.Logf("bucket cors is not set: %v", err)
|
||||
}
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
corsConfig, err := auth.ParseCORSOutput(cors)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
allowConfig, err := corsConfig.IsAllowed(origin, method, parsedHeaders)
|
||||
if err != nil {
|
||||
debuglogger.Logf("cors access forbidden: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Headers: map[string]*string{
|
||||
"Access-Control-Allow-Origin": &allowConfig.Origin,
|
||||
"Access-Control-Allow-Methods": &allowConfig.Methods,
|
||||
"Access-Control-Expose-Headers": &allowConfig.ExposedHeaders,
|
||||
"Access-Control-Allow-Credentials": &allowConfig.AllowCredentials,
|
||||
"Access-Control-Allow-Headers": &allowConfig.AllowHeaders,
|
||||
"Access-Control-Max-Age": utils.ConvertPtrToStringPtr(allowConfig.MaxAge),
|
||||
"Vary": &middlewares.VaryHdr,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
241
s3api/controllers/options_test.go
Normal file
241
s3api/controllers/options_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/s3api/middlewares"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func TestS3ApiController_CORSOptions(t *testing.T) {
|
||||
maxAge := int32(10000)
|
||||
cors, err := xml.Marshal(auth.CORSConfiguration{
|
||||
Rules: []auth.CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"example.com"},
|
||||
AllowedMethods: []auth.CORSHTTPMethod{http.MethodGet, http.MethodPost},
|
||||
AllowedHeaders: []auth.CORSHeader{"Content-Type", "Content-Disposition"},
|
||||
ExposeHeaders: []auth.CORSHeader{"Content-Encoding", "date"},
|
||||
MaxAgeSeconds: &maxAge,
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input testInput
|
||||
output testOutput
|
||||
}{
|
||||
{
|
||||
name: "missing origin",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrMissingCORSOrigin),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid method",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"Origin": "example.com",
|
||||
"Access-Control-Request-Method": "invalid_method",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetInvalidCORSMethodErr("invalid_method"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid headers",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"Origin": "example.com",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "Content Type",
|
||||
},
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetInvalidCORSRequestHeaderErr("Content Type"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fails to get bucket cors",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"Origin": "example.com",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
},
|
||||
beRes: []byte{},
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bucket cors is not enabled",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"Origin": "example.com",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
},
|
||||
beRes: []byte{},
|
||||
beErr: s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrCORSIsNotEnabled),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fails to parse bucket cors",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"Origin": "example.com",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
},
|
||||
beRes: []byte("invalid_cors"),
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: errors.New("failed to parse cors config:"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cors is not allowed",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"Origin": "example.com",
|
||||
"Access-Control-Request-Method": "PUT",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
},
|
||||
beRes: cors,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
err: s3err.GetAPIError(s3err.ErrCORSForbidden),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "success: cors is allowed",
|
||||
input: testInput{
|
||||
locals: defaultLocals,
|
||||
headers: map[string]string{
|
||||
"Origin": "example.com",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "content-type, Content-Disposition",
|
||||
},
|
||||
beRes: cors,
|
||||
},
|
||||
output: testOutput{
|
||||
response: &Response{
|
||||
Headers: map[string]*string{
|
||||
"Access-Control-Allow-Origin": utils.GetStringPtr("example.com"),
|
||||
"Access-Control-Allow-Methods": utils.GetStringPtr("GET, POST"),
|
||||
"Access-Control-Expose-Headers": utils.GetStringPtr("Content-Encoding, date"),
|
||||
"Access-Control-Allow-Credentials": utils.GetStringPtr("true"),
|
||||
"Access-Control-Allow-Headers": utils.GetStringPtr("content-type, content-disposition"),
|
||||
"Access-Control-Max-Age": utils.ConvertToStringPtr(maxAge),
|
||||
"Vary": &middlewares.VaryHdr,
|
||||
},
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
be := &BackendMock{
|
||||
GetBucketCorsFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
return tt.input.beRes.([]byte), tt.input.beErr
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := S3ApiController{
|
||||
be: be,
|
||||
}
|
||||
|
||||
testController(
|
||||
t,
|
||||
ctrl.CORSOptions,
|
||||
tt.output.response,
|
||||
tt.output.err,
|
||||
ctxInputs{
|
||||
locals: tt.input.locals,
|
||||
headers: tt.input.headers,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,72 +15,34 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/s3api/controllers"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
singlePath = regexp.MustCompile(`^/[^/]+/?$`)
|
||||
)
|
||||
|
||||
func AclParser(be backend.Backend, logger s3log.AuditLogger, readonly bool) fiber.Handler {
|
||||
// ParseAcl retreives the bucket acl and stores in the context locals
|
||||
// if no bucket is found, it returns 'NoSuchBucket'
|
||||
func ParseAcl(be backend.Backend) fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
isRoot, acct := ctx.Locals("isRoot").(bool), ctx.Locals("account").(auth.Account)
|
||||
path := ctx.Path()
|
||||
pathParts := strings.Split(path, "/")
|
||||
bucket := pathParts[1]
|
||||
if path == "/" && ctx.Method() == http.MethodGet {
|
||||
return ctx.Next()
|
||||
}
|
||||
if ctx.Method() == http.MethodPatch {
|
||||
return ctx.Next()
|
||||
}
|
||||
if singlePath.MatchString(path) &&
|
||||
ctx.Method() == http.MethodPut &&
|
||||
!ctx.Request().URI().QueryArgs().Has("acl") &&
|
||||
!ctx.Request().URI().QueryArgs().Has("tagging") &&
|
||||
!ctx.Request().URI().QueryArgs().Has("versioning") &&
|
||||
!ctx.Request().URI().QueryArgs().Has("policy") &&
|
||||
!ctx.Request().URI().QueryArgs().Has("object-lock") &&
|
||||
!ctx.Request().URI().QueryArgs().Has("ownershipControls") &&
|
||||
!ctx.Request().URI().QueryArgs().Has("cors") {
|
||||
if err := auth.MayCreateBucket(acct, isRoot); err != nil {
|
||||
return controllers.SendXMLResponse(ctx, nil, err, &controllers.MetaOpts{Logger: logger, Action: "CreateBucket"})
|
||||
}
|
||||
if readonly {
|
||||
return controllers.SendXMLResponse(ctx, nil, s3err.GetAPIError(s3err.ErrAccessDenied),
|
||||
&controllers.MetaOpts{
|
||||
Logger: logger,
|
||||
Action: "CreateBucket",
|
||||
})
|
||||
}
|
||||
return ctx.Next()
|
||||
}
|
||||
bucket := ctx.Params("bucket")
|
||||
data, err := be.GetBucketAcl(ctx.Context(), &s3.GetBucketAclInput{Bucket: &bucket})
|
||||
if err != nil {
|
||||
return controllers.SendResponse(ctx, err, &controllers.MetaOpts{Logger: logger})
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAcl, err := auth.ParseACL(data)
|
||||
if err != nil {
|
||||
return controllers.SendResponse(ctx, err, &controllers.MetaOpts{Logger: logger})
|
||||
return err
|
||||
}
|
||||
|
||||
// if owner is not set, set default owner to root account
|
||||
if parsedAcl.Owner == "" {
|
||||
parsedAcl.Owner = ctx.Locals("rootAccess").(string)
|
||||
parsedAcl.Owner = utils.ContextKeyRootAccessKey.Get(ctx).(string)
|
||||
}
|
||||
|
||||
ctx.Locals("parsedAcl", parsedAcl)
|
||||
return ctx.Next()
|
||||
utils.ContextKeyParsedAcl.Set(ctx, parsedAcl)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,45 +15,20 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/metrics"
|
||||
"github.com/versity/versitygw/s3api/controllers"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
)
|
||||
|
||||
func IsAdmin(logger s3log.AuditLogger) fiber.Handler {
|
||||
// IsAdmin is a middleware that restricts access to admin APIs, allowing only admin users
|
||||
func IsAdmin(action string) fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
acct := ctx.Locals("account").(auth.Account)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
if acct.Role != auth.RoleAdmin {
|
||||
path := ctx.Path()
|
||||
return controllers.SendResponse(ctx, s3err.GetAPIError(s3err.ErrAdminAccessDenied),
|
||||
&controllers.MetaOpts{
|
||||
Logger: logger,
|
||||
Action: detectAction(path),
|
||||
})
|
||||
return s3err.GetAPIError(s3err.ErrAdminAccessDenied)
|
||||
}
|
||||
|
||||
return ctx.Next()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func detectAction(path string) (action string) {
|
||||
if strings.Contains(path, "create-user") {
|
||||
action = metrics.ActionAdminCreateUser
|
||||
} else if strings.Contains(path, "update-user") {
|
||||
action = metrics.ActionAdminUpdateUser
|
||||
} else if strings.Contains(path, "delete-user") {
|
||||
action = metrics.ActionAdminDeleteUser
|
||||
} else if strings.Contains(path, "list-user") {
|
||||
action = metrics.ActionAdminListUsers
|
||||
} else if strings.Contains(path, "list-buckets") {
|
||||
action = metrics.ActionAdminListBuckets
|
||||
} else if strings.Contains(path, "change-bucket-owner") {
|
||||
action = metrics.ActionAdminChangeBucketOwner
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
105
s3api/middlewares/apply-bucket-cors.go
Normal file
105
s3api/middlewares/apply-bucket-cors.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
// Vary http response header is always the same below
|
||||
var VaryHdr = "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"
|
||||
|
||||
// ApplyBucketCORS retreives the bucket CORS configuration,
|
||||
// checks if origin and method meets the cors rules and
|
||||
// adds the necessary response headers.
|
||||
// CORS check is applied only when 'Origin' request header is present
|
||||
func ApplyBucketCORS(be backend.Backend) fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
bucket := ctx.Params("bucket")
|
||||
origin := ctx.Get("Origin")
|
||||
// if the origin request header is empty, skip cors validation
|
||||
if origin == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// if bucket cors is not set, skip the check
|
||||
data, err := be.GetBucketCors(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
// If CORS is not configured, S3Error will have code NoSuchCORSConfiguration.
|
||||
// In this case, we can safely continue. For any other error, we should log it.
|
||||
s3Err, ok := err.(s3err.APIError)
|
||||
if !ok || s3Err.Code != "NoSuchCORSConfiguration" {
|
||||
debuglogger.Logf("failed to get bucket cors for bucket %q: %v", bucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cors, err := auth.ParseCORSOutput(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
method := auth.CORSHTTPMethod(ctx.Get("Access-Control-Request-Method"))
|
||||
headers := ctx.Get("Access-Control-Request-Headers")
|
||||
|
||||
// if request method is not specified with Access-Control-Request-Method
|
||||
// override it with the actual request method
|
||||
if method.IsEmpty() {
|
||||
method = auth.CORSHTTPMethod(ctx.Request().Header.Method())
|
||||
} else if !method.IsValid() {
|
||||
// check if allowed method is valid
|
||||
debuglogger.Logf("invalid cors method: %s", method)
|
||||
return s3err.GetInvalidCORSMethodErr(method.String())
|
||||
}
|
||||
|
||||
// parse and validate headers
|
||||
parsedHeaders, err := auth.ParseCORSHeaders(headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allowConfig, err := cors.IsAllowed(origin, method, parsedHeaders)
|
||||
if err != nil {
|
||||
// if bucket cors rules doesn't grant access, skip
|
||||
// and don't add any response headers
|
||||
return nil
|
||||
}
|
||||
|
||||
if allowConfig.MaxAge != nil {
|
||||
ctx.Response().Header.Add("Access-Control-Max-Age", fmt.Sprint(*allowConfig.MaxAge))
|
||||
}
|
||||
|
||||
for key, val := range map[string]string{
|
||||
"Access-Control-Allow-Origin": allowConfig.Origin,
|
||||
"Access-Control-Allow-Methods": allowConfig.Methods,
|
||||
"Access-Control-Expose-Headers": allowConfig.ExposedHeaders,
|
||||
"Access-Control-Allow-Credentials": allowConfig.AllowCredentials,
|
||||
"Access-Control-Allow-Headers": allowConfig.AllowHeaders,
|
||||
"Vary": VaryHdr,
|
||||
} {
|
||||
if val != "" {
|
||||
ctx.Response().Header.Add(key, val)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,13 @@ import (
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/auth"
|
||||
"github.com/versity/versitygw/metrics"
|
||||
"github.com/versity/versitygw/s3api/controllers"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
)
|
||||
|
||||
const (
|
||||
iso8601Format = "20060102T150405Z"
|
||||
iso8601Format = "20060102T150405Z"
|
||||
maxObjSizeLimit = 5 * 1024 * 1024 * 1024 // 5gb
|
||||
)
|
||||
|
||||
type RootUserConfig struct {
|
||||
@@ -41,77 +39,91 @@ type RootUserConfig struct {
|
||||
Secret string
|
||||
}
|
||||
|
||||
func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, logger s3log.AuditLogger, mm *metrics.Manager, region string, debug bool) fiber.Handler {
|
||||
func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string) fiber.Handler {
|
||||
acct := accounts{root: root, iam: iam}
|
||||
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
// If account is set in context locals, it means it was presigned url case
|
||||
_, ok := ctx.Locals("account").(auth.Account)
|
||||
if ok {
|
||||
return ctx.Next()
|
||||
// The bucket is public, no need to check this signature
|
||||
if utils.ContextKeyPublicBucket.IsSet(ctx) {
|
||||
return nil
|
||||
}
|
||||
// If ContextKeyAuthenticated is set in context locals, it means it was presigned url case
|
||||
if utils.ContextKeyAuthenticated.IsSet(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx.Locals("region", region)
|
||||
ctx.Locals("startTime", time.Now())
|
||||
authorization := ctx.Get("Authorization")
|
||||
if authorization == "" {
|
||||
return sendResponse(ctx, s3err.GetAPIError(s3err.ErrAuthHeaderEmpty), logger, mm)
|
||||
return s3err.GetAPIError(s3err.ErrAuthHeaderEmpty)
|
||||
}
|
||||
|
||||
authData, err := utils.ParseAuthorization(authorization)
|
||||
if err != nil {
|
||||
return sendResponse(ctx, err, logger, mm)
|
||||
return err
|
||||
}
|
||||
|
||||
if authData.Region != region {
|
||||
return sendResponse(ctx, s3err.APIError{
|
||||
return s3err.APIError{
|
||||
Code: "SignatureDoesNotMatch",
|
||||
Description: fmt.Sprintf("Credential should be scoped to a valid Region, not %v", authData.Region),
|
||||
HTTPStatusCode: http.StatusForbidden,
|
||||
}, logger, mm)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Locals("isRoot", authData.Access == root.Access)
|
||||
ctx.Locals("rootAccess", root.Access)
|
||||
utils.ContextKeyIsRoot.Set(ctx, authData.Access == root.Access)
|
||||
|
||||
account, err := acct.getAccount(authData.Access)
|
||||
if err == auth.ErrNoSuchUser {
|
||||
return sendResponse(ctx, s3err.GetAPIError(s3err.ErrInvalidAccessKeyID), logger, mm)
|
||||
return s3err.GetAPIError(s3err.ErrInvalidAccessKeyID)
|
||||
}
|
||||
if err != nil {
|
||||
return sendResponse(ctx, err, logger, mm)
|
||||
return err
|
||||
}
|
||||
ctx.Locals("account", account)
|
||||
|
||||
utils.ContextKeyAccount.Set(ctx, account)
|
||||
|
||||
// Check X-Amz-Date header
|
||||
date := ctx.Get("X-Amz-Date")
|
||||
if date == "" {
|
||||
return sendResponse(ctx, s3err.GetAPIError(s3err.ErrMissingDateHeader), logger, mm)
|
||||
return s3err.GetAPIError(s3err.ErrMissingDateHeader)
|
||||
}
|
||||
|
||||
// Parse the date and check the date validity
|
||||
tdate, err := time.Parse(iso8601Format, date)
|
||||
if err != nil {
|
||||
return sendResponse(ctx, s3err.GetAPIError(s3err.ErrMalformedDate), logger, mm)
|
||||
return s3err.GetAPIError(s3err.ErrMalformedDate)
|
||||
}
|
||||
|
||||
if date[:8] != authData.Date {
|
||||
return sendResponse(ctx, s3err.GetAPIError(s3err.ErrSignatureDateDoesNotMatch), logger, mm)
|
||||
return s3err.GetAPIError(s3err.ErrSignatureDateDoesNotMatch)
|
||||
}
|
||||
|
||||
// Validate the dates difference
|
||||
err = utils.ValidateDate(tdate)
|
||||
if err != nil {
|
||||
return sendResponse(ctx, err, logger, mm)
|
||||
return err
|
||||
}
|
||||
|
||||
var contentLength int64
|
||||
contentLengthStr := ctx.Get("Content-Length")
|
||||
if contentLengthStr != "" {
|
||||
contentLength, err = strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
//TODO: not sure if InvalidRequest should be returned in this case
|
||||
if err != nil {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
hashPayload := ctx.Get("X-Amz-Content-Sha256")
|
||||
if !utils.IsValidSh256PayloadHeader(hashPayload) {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidSHA256Paylod)
|
||||
}
|
||||
if utils.IsBigDataAction(ctx) {
|
||||
// for streaming PUT actions, authorization is deferred
|
||||
// until end of stream due to need to get length and
|
||||
// checksum of the stream to validate authorization
|
||||
wrapBodyReader(ctx, func(r io.Reader) io.Reader {
|
||||
return utils.NewAuthReader(ctx, r, authData, account.Secret, debug)
|
||||
return utils.NewAuthReader(ctx, r, authData, account.Secret)
|
||||
})
|
||||
|
||||
// wrap the io.Reader with ChunkReader if x-amz-content-sha256
|
||||
@@ -124,11 +136,23 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, logger s3log.Au
|
||||
return cr
|
||||
})
|
||||
if err != nil {
|
||||
return sendResponse(ctx, err, logger, mm)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return ctx.Next()
|
||||
// Content-Length has to be set for data uploads: PutObject, UploadPart
|
||||
if contentLengthStr == "" {
|
||||
return s3err.GetAPIError(s3err.ErrMissingContentLength)
|
||||
}
|
||||
// the upload limit for big data actions: PutObject, UploadPart
|
||||
// is 5gb. If the size exceeds the limit, return 'EntityTooLarge' err
|
||||
if contentLength > maxObjSizeLimit {
|
||||
return s3err.GetAPIError(s3err.ErrEntityTooLarge)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !utils.IsSpecialPayload(hashPayload) {
|
||||
@@ -138,25 +162,16 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, logger s3log.Au
|
||||
|
||||
// Compare the calculated hash with the hash provided
|
||||
if hashPayload != hexPayload {
|
||||
return sendResponse(ctx, s3err.GetAPIError(s3err.ErrContentSHA256Mismatch), logger, mm)
|
||||
return s3err.GetAPIError(s3err.ErrContentSHA256Mismatch)
|
||||
}
|
||||
}
|
||||
|
||||
var contentLength int64
|
||||
contentLengthStr := ctx.Get("Content-Length")
|
||||
if contentLengthStr != "" {
|
||||
contentLength, err = strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err != nil {
|
||||
return sendResponse(ctx, s3err.GetAPIError(s3err.ErrInvalidRequest), logger, mm)
|
||||
}
|
||||
}
|
||||
|
||||
err = utils.CheckValidSignature(ctx, authData, account.Secret, hashPayload, tdate, contentLength, debug)
|
||||
err = utils.CheckValidSignature(ctx, authData, account.Secret, hashPayload, tdate, contentLength)
|
||||
if err != nil {
|
||||
return sendResponse(ctx, err, logger, mm)
|
||||
return err
|
||||
}
|
||||
|
||||
return ctx.Next()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,13 +185,9 @@ func (a accounts) getAccount(access string) (auth.Account, error) {
|
||||
return auth.Account{
|
||||
Access: a.root.Access,
|
||||
Secret: a.root.Secret,
|
||||
Role: "admin",
|
||||
Role: auth.RoleAdmin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return a.iam.GetUserAccount(access)
|
||||
}
|
||||
|
||||
func sendResponse(ctx *fiber.Ctx, err error, logger s3log.AuditLogger, mm *metrics.Manager) error {
|
||||
return controllers.SendResponse(ctx, err, &controllers.MetaOpts{Logger: logger, MetricsMng: mm})
|
||||
}
|
||||
|
||||
@@ -15,17 +15,24 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
)
|
||||
|
||||
func wrapBodyReader(ctx *fiber.Ctx, wr func(io.Reader) io.Reader) {
|
||||
r, ok := ctx.Locals("body-reader").(io.Reader)
|
||||
r, ok := utils.ContextKeyBodyReader.Get(ctx).(io.Reader)
|
||||
if !ok {
|
||||
r = ctx.Request().BodyStream()
|
||||
// Override the body reader with an empty reader to prevent panics
|
||||
// in case of unexpected or malformed HTTP requests.
|
||||
if r == nil {
|
||||
r = bytes.NewBuffer([]byte{})
|
||||
}
|
||||
}
|
||||
|
||||
r = wr(r)
|
||||
ctx.Locals("body-reader", r)
|
||||
utils.ContextKeyBodyReader.Set(ctx, r)
|
||||
}
|
||||
|
||||
42
s3api/middlewares/bucket-object-name-validator.go
Normal file
42
s3api/middlewares/bucket-object-name-validator.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
// BucketObjectNameValidator extracts and validates
|
||||
// the bucket and object names from the request URI.
|
||||
func BucketObjectNameValidator() fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
bucket, object := parsePath(ctx.Path())
|
||||
|
||||
// check if the provided bucket name is valid
|
||||
if !utils.IsValidBucketName(bucket) {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidBucketName)
|
||||
}
|
||||
|
||||
// check if the provided object name is valid
|
||||
// skip for empty objects: e.g bucket operations: HeadBucket...
|
||||
if object != "" && !utils.IsObjectNameValid(object) {
|
||||
return s3err.GetAPIError(s3err.ErrBadRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
40
s3api/middlewares/host-style-parser.go
Normal file
40
s3api/middlewares/host-style-parser.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright 2023 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// HostStyleParser is a middleware which parses the bucket name
|
||||
// from the 'Host' header and appends in the request URL path
|
||||
func HostStyleParser(virtualDomain string) fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
host := string(ctx.Request().Host())
|
||||
// the host should match this pattern: '<bucket_name>.<virtual_domain>'
|
||||
bucket, _, found := strings.Cut(host, "."+virtualDomain)
|
||||
if !found || bucket == "" {
|
||||
return ctx.Next()
|
||||
}
|
||||
path := ctx.Path()
|
||||
pathStyleUrl := fmt.Sprintf("/%v%v", bucket, path)
|
||||
ctx.Path(pathStyleUrl)
|
||||
|
||||
return ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -15,30 +15,15 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
)
|
||||
|
||||
func RequestLogger(isDebug bool) fiber.Handler {
|
||||
func DebugLogger() fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
ctx.Locals("isDebug", isDebug)
|
||||
if isDebug {
|
||||
log.Println("Request headers: ")
|
||||
ctx.Request().Header.VisitAll(func(key, val []byte) {
|
||||
log.Printf("%s: %s", key, val)
|
||||
})
|
||||
|
||||
if ctx.Request().URI().QueryArgs().Len() != 0 {
|
||||
fmt.Println()
|
||||
log.Println("Request query arguments: ")
|
||||
ctx.Request().URI().QueryArgs().VisitAll(func(key, val []byte) {
|
||||
log.Printf("%s: %s", key, val)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.Next()
|
||||
debuglogger.LogFiberRequestDetails(ctx)
|
||||
err := ctx.Next()
|
||||
debuglogger.LogFiberResponseDetails(ctx)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,17 +19,15 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/s3api/controllers"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
)
|
||||
|
||||
func VerifyMD5Body(logger s3log.AuditLogger) fiber.Handler {
|
||||
func VerifyMD5Body() fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
incomingSum := ctx.Get("Content-Md5")
|
||||
if incomingSum == "" {
|
||||
return ctx.Next()
|
||||
return nil
|
||||
}
|
||||
|
||||
if utils.IsBigDataAction(ctx) {
|
||||
@@ -39,18 +37,18 @@ func VerifyMD5Body(logger s3log.AuditLogger) fiber.Handler {
|
||||
return r
|
||||
})
|
||||
if err != nil {
|
||||
return controllers.SendResponse(ctx, err, &controllers.MetaOpts{Logger: logger})
|
||||
return err
|
||||
}
|
||||
return ctx.Next()
|
||||
return nil
|
||||
}
|
||||
|
||||
sum := md5.Sum(ctx.Body())
|
||||
calculatedSum := utils.Base64SumString(sum[:])
|
||||
|
||||
if incomingSum != calculatedSum {
|
||||
return controllers.SendResponse(ctx, s3err.GetAPIError(s3err.ErrInvalidDigest), &controllers.MetaOpts{Logger: logger})
|
||||
return s3err.GetAPIError(s3err.ErrInvalidDigest)
|
||||
}
|
||||
|
||||
return ctx.Next()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user