Compare commits

..

13 Commits

Author SHA1 Message Date
Noooste b8061e5109 test(handlers): cover upstream error mapping to API response codes 2026-04-20 00:23:55 +02:00
Noooste cfa13be75b refactor(handlers): use apierr.Respond for key/permission upstream errors 2026-04-20 00:21:49 +02:00
Noooste adb430c2b2 refactor(handlers): use apierr.Respond for object upstream errors 2026-04-20 00:20:15 +02:00
Noooste 849840b808 refactor(handlers): use apierr.Respond for bucket upstream errors 2026-04-20 00:18:15 +02:00
Noooste 23d63be980 refactor(services): return *UpstreamError from S3 operations via FromMinio 2026-04-20 00:16:13 +02:00
Noooste 5b12c8121f refactor(services): propagate UpstreamError unchanged from admin callers
Strip redundant fmt.Errorf wrapping at every decodeResponse call site so
the typed *apierr.UpstreamError propagates directly to handlers without
an extra indirection layer.
2026-04-20 00:12:48 +02:00
Noooste 785d21d38c feat(ci): update Dockerfile and CI configuration for backend image and caching
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-20 00:12:07 +02:00
Noooste c2e99afb8b refactor(services): return *UpstreamError from decodeResponse
Replace the ad-hoc fmt.Errorf non-2xx branch with apierr.ParseGarage so
decodeResponse now returns a typed *apierr.UpstreamError on error.
Update two admin_test.go tests that string-matched the old error shape to
assert UpstreamError fields (HTTPStatus, Message) instead.
2026-04-20 00:09:01 +02:00
Noooste 691ed8db4d feat(apierr): map upstream codes to API status/code and add Respond helper 2026-04-20 00:05:59 +02:00
Noooste caf8d9fd2e feat(apierr): convert MinIO errors to UpstreamError via FromMinio 2026-04-20 00:02:53 +02:00
Noooste f2cc660c8f feat(apierr): parse Garage structured error responses into UpstreamError 2026-04-20 00:01:31 +02:00
Noooste 5c2e5dbf6f feat(apierr): add UpstreamError type for typed upstream failures 2026-04-19 23:59:54 +02:00
Noooste adfd044798 feat(api): add BUCKET_NOT_EMPTY/KEY_NOT_FOUND/INVALID_REQUEST error codes 2026-04-19 23:58:34 +02:00
152 changed files with 2677 additions and 14512 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 344 KiB

-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+12 -8
View File
@@ -3,9 +3,7 @@ name: build
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
- 'v*'
jobs:
build:
@@ -16,9 +14,11 @@ jobs:
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
include:
- platform: linux/amd64
arch: amd64
- platform: linux/arm64
arch: arm64
steps:
- name: Checkout repository
@@ -54,8 +54,12 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ secrets.DOCKER_REGISTRY }}:buildcache-${{ matrix.arch }}
cache-to: |
type=gha,mode=max,scope=build-${{ matrix.arch }}
type=registry,ref=${{ secrets.DOCKER_REGISTRY }}:buildcache-${{ matrix.arch }},mode=max
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
-81
View File
@@ -1,81 +0,0 @@
name: chart-release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
permissions:
contents: write
pages: write
packages: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v4.1.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
skip_existing: true
env:
CR_TOKEN: ${{ secrets.HELM_RELEASE_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v4.1.2
- name: Log in to ghcr.io for cosign
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Package and push chart to ghcr.io (OCI)
id: oci_push
env:
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
chart_version=$(grep -E '^version:' helm/garage-ui/Chart.yaml | awk '{print $2}')
echo "Packaging chart version ${chart_version}"
helm package helm/garage-ui --destination /tmp/chart
echo "${GHCR_TOKEN}" | helm registry login ghcr.io \
--username "${GHCR_USER}" --password-stdin
push_output=$(helm push "/tmp/chart/garage-ui-${chart_version}.tgz" \
oci://ghcr.io/noooste/charts 2>&1 | tee /dev/stderr)
digest=$(echo "$push_output" | grep -oE 'sha256:[a-f0-9]{64}' | head -n1)
if [ -z "$digest" ]; then
echo "Failed to parse pushed digest from helm push output" >&2
exit 1
fi
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
echo "Pushed oci://ghcr.io/noooste/charts/garage-ui:${chart_version} (${digest})"
- name: Sign chart with cosign (keyless)
run: |
cosign sign --yes \
"ghcr.io/noooste/charts/garage-ui@${{ steps.oci_push.outputs.digest }}"
-38
View File
@@ -1,38 +0,0 @@
name: pr-title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
docs
chore
refactor
test
ci
build
perf
scopes: |
backend
frontend
helm
ci
deps
requireScope: false
subjectPattern: ^[A-Za-z].+[^.]$
subjectPatternError: |
PR title subject must start with a letter and not end with a period.
Example: "feat(helm): add support for extraEnvs"
-27
View File
@@ -1,27 +0,0 @@
name: release-please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.RELEASE_PLEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_APP_KEY }}
- id: rp
uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ steps.app-token.outputs.token }}
+37
View File
@@ -0,0 +1,37 @@
name: release
on:
push:
branches:
- main
paths:
- 'helm/garage-ui/**'
- '!helm/garage-ui/README.md'
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v3.19.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
env:
CR_TOKEN: "${{ secrets.HELM_RELEASE_TOKEN }}"
+15 -5
View File
@@ -11,9 +11,6 @@ on:
push:
branches: [main]
permissions:
contents: read
jobs:
unit:
name: Unit tests + coverage gate
@@ -30,13 +27,13 @@ jobs:
- name: Generate swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@latest
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd backend && swag init
- name: Run unit tests with race detector and coverage
run: |
cd backend
go test -race -count=1 -coverprofile=../coverage.out -coverpkg=./... ./...
go test -race -coverprofile=../coverage.out -coverpkg=./... ./...
- name: Enforce coverage gate
run: bash scripts/coverage-gate.sh coverage.out
@@ -69,6 +66,19 @@ jobs:
cache: true
cache-dependency-path: backend/go.sum
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Pre-build backend image with layer cache
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
load: true
tags: garage-ui-smoke-backend:ci
cache-from: type=gha,scope=smoke-backend
cache-to: type=gha,mode=max,scope=smoke-backend
- name: Run smoke test
run: make test-smoke
+3 -6
View File
@@ -52,7 +52,7 @@ dist-ssr
*.sw?
.env*
!config.example.yaml
!config.yaml.example
docker-compose.*.yml
!backend/tests/smoke/docker-compose.test.yml
@@ -64,9 +64,6 @@ garage.toml
backend/docs/
config.yaml
docs/**/*.md
!docs/garage-setup.md
!docs/access-control.md
**/state**
**/content**
**/worktrees
# Superpowers brainstorm / session scratch
.superpowers/
-3
View File
@@ -1,3 +0,0 @@
{
".": "0.11.0"
}
-116
View File
@@ -1,116 +0,0 @@
# Changelog
## [0.11.0](https://github.com/Noooste/garage-ui/compare/v0.10.0...v0.11.0) (2026-07-25)
### Features
* **bucket:** add functionality to create buckets with optional access keys and permissions ([6766dec](https://github.com/Noooste/garage-ui/commit/6766dec933f85ece64d89875ccc147c069669a0c))
* **dropdown:** improve positioning logic and add popup positioning utility ([985b7b3](https://github.com/Noooste/garage-ui/commit/985b7b30ce1cc450f621169068efcfa1dccd2a25))
* **frontend:** make bucket items clickable links to object view ([a061500](https://github.com/Noooste/garage-ui/commit/a061500d50ddd1ec1cc713c06fd24a9b9f1511ed))
## [0.10.0](https://github.com/Noooste/garage-ui/compare/v0.9.0...v0.10.0) (2026-07-14)
### Features
* bulk actions — recursively delete folders (key prefixes) ([#68](https://github.com/Noooste/garage-ui/issues/68)) ([5d33382](https://github.com/Noooste/garage-ui/commit/5d33382e3056afd5b75b1775255e0f3033833f6c))
* **metrics:** add public metrics endpoint configuration and update documentation ([#92](https://github.com/Noooste/garage-ui/issues/92)) ([9f73d03](https://github.com/Noooste/garage-ui/commit/9f73d032e6c1c10b7d0dafa986e32985aa3b6f66))
* **preview:** Implement object preview functionality ([#94](https://github.com/Noooste/garage-ui/issues/94)) ([c0976a1](https://github.com/Noooste/garage-ui/commit/c0976a1e1d53839d1fe9d7158e8cadb979789722))
## [0.9.0](https://github.com/Noooste/garage-ui/compare/v0.8.5...v0.9.0) (2026-07-11)
### Features
* **backend,frontend:** implement prefix and recursive substring search for bucket objects ([#89](https://github.com/Noooste/garage-ui/issues/89)) ([3098e47](https://github.com/Noooste/garage-ui/commit/3098e474f202f36b527c8636cd1d4e8c08e287d2))
* **oidc:** add fine grained access control ([#91](https://github.com/Noooste/garage-ui/issues/91)) ([0d804fb](https://github.com/Noooste/garage-ui/commit/0d804fbd39ed656511b671d237ad14fc8c21786d))
## [0.8.5](https://github.com/Noooste/garage-ui/compare/v0.8.4...v0.8.5) (2026-07-03)
### Bug Fixes
* **frontend:** add downloadObject function for downloading files from a bucket ([430d1a5](https://github.com/Noooste/garage-ui/commit/430d1a5d68e4f9915a2951d5b31c1b5ea56cb24c))
## [0.8.4](https://github.com/Noooste/garage-ui/compare/v0.8.3...v0.8.4) (2026-06-22)
### Bug Fixes
* **helm:** track appVersion in release-please and fix badges ([1ffdfe8](https://github.com/Noooste/garage-ui/commit/1ffdfe83c884dc5e39d071343d5269164746c536))
## [0.8.3](https://github.com/Noooste/garage-ui/compare/v0.8.2...v0.8.3) (2026-06-21)
### Bug Fixes
* **backend:** improve API version detection with retry logic for health probes ([46aa375](https://github.com/Noooste/garage-ui/commit/46aa3752c81788787388d1c67e29cef786bdabff))
* **helm:** update version badges in README for Garage UI ([28c186f](https://github.com/Noooste/garage-ui/commit/28c186f3eba1a1c111100712f1eaec22a5d18eb2))
## [0.8.2](https://github.com/Noooste/garage-ui/compare/v0.8.1...v0.8.2) (2026-06-07)
### Bug Fixes
* **backend:** prevent OIDC login loop from empty cookie name ([#76](https://github.com/Noooste/garage-ui/issues/76)) ([22be89b](https://github.com/Noooste/garage-ui/commit/22be89b2ff86465abb90dab0344ef9366ab181b3))
## [0.8.1](https://github.com/Noooste/garage-ui/compare/v0.8.0...v0.8.1) (2026-05-31)
### Bug Fixes
* **frontend:** align three-dot menu item icon spacing and text alignment ([#72](https://github.com/Noooste/garage-ui/issues/72)) ([45f8770](https://github.com/Noooste/garage-ui/commit/45f87707996e92d0f8f75e79c8f60a13556eaf6e))
## [0.8.0](https://github.com/Noooste/garage-ui/compare/v0.7.0...v0.8.0) (2026-05-31)
### ⚠ BREAKING CHANGES
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support
### Features
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support ([5427758](https://github.com/Noooste/garage-ui/commit/5427758eaadc4fa1327402b958b7e7e1f43aecdd))
* **docs:** add documentation generation command to Makefile ([186af18](https://github.com/Noooste/garage-ui/commit/186af18d54f739bd9b467cbf3ab9ccc2e92ddf62))
## [0.7.0](https://github.com/Noooste/garage-ui/compare/v0.6.2...v0.7.0) (2026-05-23)
### Features
* **backend,frontend:** enable quotas support in bucket settings ([#64](https://github.com/Noooste/garage-ui/issues/64)) ([1f16edd](https://github.com/Noooste/garage-ui/commit/1f16edd39cfa2f3a51cb576871642c9d23545781))
* **backend:** Support _FILE suffix on sensitive env variables ([#63](https://github.com/Noooste/garage-ui/issues/63)) ([36ec8e8](https://github.com/Noooste/garage-ui/commit/36ec8e800ea0ff5306e4d0f9f4aea4f72a5a109b))
### Bug Fixes
* **auth:** remove auto-enable token auth logic ([1b645b0](https://github.com/Noooste/garage-ui/commit/1b645b0c2c6e05dea98a7fc5d7595ca015913770))
## [0.6.2](https://github.com/Noooste/garage-ui/compare/v0.6.1...v0.6.2) (2026-05-15)
### Bug Fixes
* **helm:** update appVersion format and improve image tag handling ([709b9f2](https://github.com/Noooste/garage-ui/commit/709b9f2ad33fb3852bc23be1d647bb1d9388169b))
## [0.6.1](https://github.com/Noooste/garage-ui/compare/v0.6.0...v0.6.1) (2026-05-15)
### Bug Fixes
* Helm image tag ([#53](https://github.com/Noooste/garage-ui/issues/53)) ([ff46ff6](https://github.com/Noooste/garage-ui/commit/ff46ff623299461abade27478128f7e5ce409557))
## [0.6.0](https://github.com/Noooste/garage-ui/compare/v0.5.0...v0.6.0) (2026-05-15)
### Features
* add extraEnvs to helm chart to allow config override ([#45](https://github.com/Noooste/garage-ui/issues/45)) ([c8337de](https://github.com/Noooste/garage-ui/commit/c8337de3a885fc86a08a9be329a60c0846e52d62))
* enhance bucket credential retrieval to support read/write operations and improve caching logic ([#46](https://github.com/Noooste/garage-ui/issues/46)) ([c8cb3c4](https://github.com/Noooste/garage-ui/commit/c8cb3c49239bcf21b0abacba86622d55a202c4bd))
### Bug Fixes
* **ci:** correct appVersion tracking and remove changelog seeds ([#49](https://github.com/Noooste/garage-ui/issues/49)) ([f04c381](https://github.com/Noooste/garage-ui/commit/f04c38168d026c9746c5fb9f8182cb9fadc06f53))
* **ci:** remove CHANGELOG.md seeds so release-please owns them ([#51](https://github.com/Noooste/garage-ui/issues/51)) ([cb3839e](https://github.com/Noooste/garage-ui/commit/cb3839e6189ec1d2a6609ff500ab7a79d0f5cbd9))
-58
View File
@@ -130,61 +130,3 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/Nooost
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
- You may want to **include screenshots or screen recordings** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [LICEcap](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and the built-in [screen recorder in GNOME](https://help.gnome.org/users/gnome-help/stable/screen-shot-record.html.en) or [SimpleScreenRecorder](https://github.com/MaartenBaert/ssr) on Linux. <!-- this should only be included if the project has a GUI -->
- **Explain why this enhancement would be useful** to most Garage UI users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
## Commit Messages
This repo uses [Conventional Commits](https://www.conventionalcommits.org/) on
PR titles to drive automated releases and changelogs via
[release-please](https://github.com/googleapis/release-please).
PRs are squash-merged, so **only the PR title needs to follow the format**.
Branch commits can be anything.
### Format
```
<type>(<optional-scope>): <subject>
```
### Allowed types
| Type | Use when… | Triggers release? |
|------------|------------------------------------------|-------------------|
| `feat` | adding new user-facing functionality | minor bump |
| `fix` | fixing a bug | patch bump |
| `feat!` | breaking change (pre-1.0: still minor) | minor bump |
| `perf` | performance improvement | patch bump |
| `docs` | documentation only | no |
| `refactor` | code change that's not feat/fix | no |
| `chore` | tooling, deps, build | no |
| `test` | adding/fixing tests | no |
| `ci` | CI workflow changes | no |
| `build` | build system changes | no |
Pre-1.0 SemVer: while the project is `<1.0`, breaking changes (`feat!`) bump
the **minor** version, not the major. Once the project declares `1.0.0`,
`feat!` will bump major as per standard SemVer.
### Allowed scopes
- `backend` — Go API server
- `frontend` — React app
- `helm` — Helm chart
- `ci` — CI workflows
- `deps` — dependency updates
Scope is optional. Use it when the change is clearly scoped to one component
(it routes the version bump to that component only).
### Examples
```
feat(backend): add bucket quota enforcement
fix(frontend): correct theme toggle in Safari
feat(helm): support extraEnvs in deployment template
chore(deps): bump axios from 1.15.0 to 1.15.2
docs: clarify OIDC setup in README
```
The PR title is automatically validated by the `pr-title` GitHub Action.
+3 -2
View File
@@ -20,7 +20,7 @@ WORKDIR /app
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go install github.com/swaggo/swag/cmd/swag@latest
go install github.com/swaggo/swag/cmd/swag@v1.16.4
COPY backend/go.mod backend/go.sum ./
@@ -48,7 +48,7 @@ RUN addgroup -g 1000 garageui && \
adduser -D -u 1000 -G garageui garageui
COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui .
COPY --from=frontend-builder --chown=garageui:garageui /app/frontend/dist ./frontend/dist
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
USER garageui
@@ -58,3 +58,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./garage-ui"]
-6
View File
@@ -154,9 +154,3 @@ test-cover:
## test-smoke: Run the docker compose smoke test (requires Docker + compose v2)
test-smoke:
cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/...
docs:
@echo "Generating documentation..."
@mkdir -p docs
@echo "Documentation generated in the 'docs' directory."
swag init -g backend/cmd/garage-ui/main.go -o docs --parseDependency --parseInternal
+127 -155
View File
@@ -1,15 +1,13 @@
<p align="center">
<a href="https://github.com/Noooste/garage-ui/actions/workflows/build.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg" alt="Docker Build" /></a>
<a href="https://github.com/Noooste/garage-ui/actions/workflows/chart-release.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/chart-release.yml/badge.svg" alt="Helm Chart" /></a>
<a href="https://codecov.io/gh/Noooste/garage-ui"><img src="https://codecov.io/gh/Noooste/garage-ui/branch/main/graph/badge.svg" alt="Coverage" /></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go" alt="Go Version" /></a>
<a href="https://artifacthub.io/packages/search?repo=garage-ui"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui" alt="Artifact Hub" /></a>
</p>
# Garage UI
# Garage UI - Web Dashboard for Garage S3 Storage
A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object storage clusters.
A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garage</a> object storage clusters. Browse buckets, manage access keys, monitor your cluster, all from your browser.
[![Docker Build](https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
[![Helm Chart](https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
[![Coverage](https://codecov.io/gh/Noooste/garage-ui/branch/main/graph/badge.svg)](https://codecov.io/gh/Noooste/garage-ui)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
---
@@ -26,32 +24,53 @@ A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garag
## Features
- **Bucket management** - create, configure, and browse buckets with drag-and-drop file uploads
- **Access key management** - create keys, assign per-bucket permissions
- **Cluster overview** - monitor node status, layout configuration, and storage usage
- **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
- **Multi-user access control** - optional OIDC-team-based permissions, see [docs/access-control.md](docs/access-control.md)
- **Easy deployment** - single Docker image or Helm chart, configure with one YAML file
- **Preview common file types** - images, video, PDF, and text without downloading
- Bucket and object management
- User access control
- Cluster monitoring
- Multiple authentication options (none/basic/OIDC)
- Drag-and-drop file uploads
## Quick Start
### Prerequisites
- Docker & Docker Compose
- A running Garage cluster (v2.1.0+) - [setup guide](docs/garage-setup.md) if you need one
- Garage S3 cluster (v2.1.0+) or use the included setup
### 1. Clone & Configure
### 1. Clone & Setup
```bash
git clone https://github.com/Noooste/garage-ui.git
cd garage-ui
cp config.example.yaml config.yaml
```
### 2. Start Garage
If you don't have Garage running:
```bash
docker compose up -d garage
sleep 10
# Initialize cluster
docker compose exec garage garage layout assign -z dc1 -c 1G $(docker compose exec garage garage node id -q)
docker compose exec garage garage layout apply --version 1
# Create admin key
docker compose exec garage garage key create admin-key
```
Save the access key and secret key from the output.
### 3. Configure
```bash
cp config.yaml.example config.yaml
```
Edit `config.yaml` with your Garage endpoints and admin token (from `garage.toml`).
### 2. Start
### 4. Start UI
```bash
docker compose up -d garage-ui
@@ -59,6 +78,43 @@ docker compose up -d garage-ui
Access at http://localhost:8080
## Configuration
Minimum required config:
```yaml
server:
port: 8080
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
region: "garage"
```
Enable authentication (optional):
```yaml
auth:
admin:
enabled: true
username: "admin"
password: "your-password"
```
See [config.yaml.example](config.yaml.example) for all options.
### Environment Variables
Override any config value with `GARAGE_UI_` prefix:
```bash
GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
```
## Deployment
### Docker
@@ -79,114 +135,22 @@ helm install garage-ui garage-ui/garage-ui \
--set garage.adminToken=your-token
```
The chart creates a ClusterIP service on port 80. To try it out before setting up an ingress:
## Development
Backend (Go 1.25+):
```bash
kubectl port-forward svc/garage-ui 8080:80
cd backend
go run main.go --config ../config.yaml
```
Then open http://localhost:8080
### Reusing your garage.toml
If you already have a running Garage instance, you can point Garage UI straight at your `garage.toml` and skip `config.yaml` entirely:
Frontend (Node.js 25+):
```bash
./garage-ui --garage-toml /etc/garage.toml
cd frontend
npm install
npm run dev
```
Garage UI reads the S3 endpoint, admin endpoint, admin token, and S3 region straight from the TOML file. When no authentication method is explicitly configured, **token auth auto-enables**: the login page asks for the Garage admin token, giving you a login wall with zero extra config.
**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside a container this won't work, so override the endpoints explicitly with environment variables or a config file.
**Docker:**
```bash
docker run -d -p 8080:8080 \
-v /etc/garage.toml:/etc/garage.toml:ro \
-e GARAGE_UI_GARAGE_TOML=/etc/garage.toml \
-e GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900 \
-e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903 \
noooste/garage-ui:latest
```
The endpoint overrides are needed because the container cannot reach `127.0.0.1` on the host.
**Combining flags:** Use `--garage-toml` for Garage connection values and `--config` for everything else (auth, CORS, logging, etc.):
```bash
./garage-ui --garage-toml /etc/garage.toml --config config.yaml
```
**Precedence order** (highest wins): built-in defaults < `garage.toml` < `config.yaml` < environment variables.
## Configuration
Minimum required config:
```yaml
server:
port: 8080
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
region: "garage"
```
Server bind host is configured by `server.host` (default: `::`). IPv6 literals like `::` and `::1` are supported.
```yaml
server:
host: "::" # IPv6 wildcard (dual-stack-preferred)
port: 8080
```
If your environment needs explicit IPv4-only binding, set `server.host: "0.0.0.0"`.
See [config.example.yaml](config.example.yaml) for all options including authentication, CORS, and logging.
### Environment Variables
Override any config value with `GARAGE_UI_` prefix:
```bash
GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
```
#### Loading sensitive values from files (`_FILE` suffix)
For Docker and Kubernetes secrets, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui uses the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file stops startup.
Supported vars:
- `GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE`
- `GARAGE_UI_AUTH_ADMIN_USERNAME_FILE`
- `GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE`
- `GARAGE_UI_AUTH_JWT_PRIVATE_KEY_FILE`
- `GARAGE_UI_AUTH_OIDC_CLIENT_ID_FILE`
- `GARAGE_UI_AUTH_OIDC_CLIENT_SECRET_FILE`
Example with Docker Compose secrets:
```yaml
services:
garage-ui:
image: noooste/garage-ui:latest
environment:
GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
secrets:
- admin_password
secrets:
admin_password:
file: ./admin_password.txt
```
This matches the convention used by the official Postgres and MySQL Docker images. Helm users don't need it; the chart already injects secrets via `existingSecret` references.
API docs: http://localhost:8080/api/v1/
## Garage Configuration
@@ -208,23 +172,6 @@ api_bind_addr = "[::]:3900" # Default: 127.0.0.1:3900
For complete Garage configuration, see the [official documentation](https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/).
## Development
Backend (Go 1.25+):
```bash
cd backend
go run main.go --config ../config.yaml
```
Frontend (Node.js 25+):
```bash
cd frontend
npm install
npm run dev
```
API docs: http://localhost:8080/api/v1/
## Troubleshooting
**Connection failed:**
@@ -241,19 +188,48 @@ logging:
## Roadmap
Roughly ordered by value. Open an [issue](https://github.com/Noooste/garage-ui/issues) to push something up the list.
Ideas being considered. Contributions welcome.
- [x] **Fine-grained access control**: OIDC teams with per-bucket-prefix permissions, see [docs/access-control.md](docs/access-control.md)
- [x] **Object search**: recursive substring search across a bucket
- [x] **Bucket quotas**: size and object count limits from bucket settings
- [x] **Zero-config startup**: run straight from `garage.toml`, log in with the admin token
- [x] **Broad compatibility**: Garage v1 through latest, IPv6-only networks, secrets from files
- [X] **Inline object preview**: images, video, PDF, and text without downloading ([#60](https://github.com/Noooste/garage-ui/issues/60))
- [ ] **Presigned share links**: time-limited download links from the object browser
- [ ] **Resumable uploads**: multipart uploads that survive a dropped connection
- [ ] **Visual layout editor**: staged vs. applied diff before committing layout changes
- [ ] **Admin audit log**: who changed what, building on access control
- [ ] **Table and detail polish**: sortable columns, clearer node details ([#36](https://github.com/Noooste/garage-ui/issues/36), [#37](https://github.com/Noooste/garage-ui/issues/37))
**Object browser**
- [ ] Inline preview (images, PDF, video, text/markdown, code)
- [ ] Resumable multipart uploads with pause/resume
- [ ] Folder uploads preserving prefix structure
- [ ] Bulk actions (delete, copy prefix, download prefix as zip)
- [ ] Command palette (Cmd-K) and keyboard navigation
**Sharing**
- [ ] Presigned download links with expiry + QR code
- [ ] Presigned upload drop-zones ("send me a file" pages)
**Buckets**
- [ ] Bucket alias manager (global vs. user-scoped)
- [ ] Quota editor with live usage bar
- [ ] Lifecycle editor (expiration + abort-multipart)
- [ ] CORS editor with built-in test request
- [ ] Website config (index/error docs) with live link
- [ ] Per-bucket usage graph over time
**Access keys**
- [ ] Permission matrix view (keys × buckets)
- [ ] Key rotation helper
- [ ] Copy-ready snippets per key (aws-cli, rclone, restic, s3cmd, mc, Terraform)
**Cluster**
- [ ] Visual layout editor with staged vs. applied diff
- [ ] Capacity planner / simulation
- [ ] Rebalance progress and node health timeline
- [ ] Worker/repair panel (trigger scrub, repair, rebalance)
**Observability**
- [ ] Dashboard with dedup/compression savings
- [ ] Metrics explorer pulling from Garage `/metrics`
- [ ] Admin audit log
**Polish**
- [ ] i18n (FR/EN)
- [ ] Mobile-friendly object browser
- [ ] First-run onboarding wizard
- [ ] GitOps export (layout + buckets + keys as YAML)
## License
@@ -264,7 +240,3 @@ MIT - see [LICENSE](LICENSE)
- [Issues](https://github.com/Noooste/garage-ui/issues)
- [Contributing](CONTRIBUTING.md)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
---
<p align="center">Made with ❤️ in France 🇫🇷</p>
-209
View File
@@ -1,209 +0,0 @@
// Command seed bulk-loads a Garage/S3 bucket with millions of small objects
// for testing the object browser and search features.
//
// Keys are a deterministic function of a global index, so a run is resumable:
// re-run with -start=<last index> and any re-uploaded key simply overwrites.
// Layout: pets/<species>/<breed>/<name>-<NNNNNN>.dat
//
// cd backend && go run ./cmd/seed # full 3,000,000 objects
// go run ./cmd/seed -count 10000 # quick smoke test
// go run ./cmd/seed -start 1500000 # resume from index 1.5M
package main
import (
"bytes"
"context"
"flag"
"fmt"
"log"
"math/rand"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// species -> breeds. Real words so keys are searchable by substring
// (e.g. "golden", "retriever", "siamese"). Flattened into speciesBreed at init.
var taxonomy = []struct {
species string
breeds []string
}{
{"dogs", []string{"labrador", "golden-retriever", "german-shepherd", "bulldog", "poodle", "beagle", "rottweiler", "dachshund", "husky", "chihuahua"}},
{"cats", []string{"siamese", "persian", "maine-coon", "bengal", "ragdoll", "sphynx", "british-shorthair", "abyssinian"}},
{"birds", []string{"parrot", "canary", "cockatiel", "budgie", "finch", "macaw", "lovebird"}},
{"fish", []string{"goldfish", "guppy", "betta", "angelfish", "tetra", "molly"}},
{"rabbits", []string{"holland-lop", "netherland-dwarf", "rex", "lionhead", "flemish-giant"}},
{"hamsters", []string{"syrian", "dwarf-campbell", "roborovski", "chinese"}},
{"reptiles", []string{"leopard-gecko", "iguana", "bearded-dragon", "corn-snake", "box-turtle"}},
{"horses", []string{"arabian", "thoroughbred", "mustang", "clydesdale", "appaloosa"}},
{"guinea-pigs", []string{"american", "abyssinian", "peruvian", "silkie"}},
{"ferrets", []string{"sable", "albino", "cinnamon", "chocolate"}},
}
// petNames are the leaf file names. ~50 common pet names.
var petNames = []string{
"buddy", "luna", "max", "bella", "charlie", "lucy", "cooper", "daisy", "rocky", "molly",
"bailey", "sadie", "duke", "maggie", "bear", "sophie", "tucker", "chloe", "oliver", "lola",
"jack", "zoe", "toby", "ruby", "teddy", "rosie", "milo", "gracie", "oscar", "coco",
"leo", "penny", "rex", "willow", "sam", "honey", "gus", "ginger", "murphy", "olive",
"jasper", "hazel", "finn", "ivy", "louie", "pepper", "ziggy", "nala", "apollo", "cleo",
}
type speciesBreedPair struct{ species, breed string }
var speciesBreed []speciesBreedPair
func init() {
for _, t := range taxonomy {
for _, b := range t.breeds {
speciesBreed = append(speciesBreed, speciesBreedPair{t.species, b})
}
}
}
// keyFor maps a global index to a unique object key with even folder fill.
// folderIdx selects the species/breed folder; within selects (name, suffix)
// inside that folder. The mapping is a bijection, so keys never collide.
func keyFor(i int64) string {
c := int64(len(speciesBreed))
n := int64(len(petNames))
folderIdx := i % c
within := i / c
name := petNames[within%n]
suffix := within / n
sb := speciesBreed[folderIdx]
return fmt.Sprintf("pets/%s/%s/%s-%06d.dat", sb.species, sb.breed, name, suffix)
}
func main() {
var (
endpoint = flag.String("endpoint", "localhost:3900", "S3 endpoint host:port")
bucket = flag.String("bucket", "test", "target bucket")
region = flag.String("region", "garage", "S3 region")
accessKey = flag.String("access-key", "GK4b706791e6efb7bc00a99c69", "S3 access key")
secretKey = flag.String("secret-key", "cdb665539872887e4fca34841ad2ebd79cda7af2302b500097262ec030123b14", "S3 secret key")
count = flag.Int64("count", 3_000_000, "total dataset size (upper index, exclusive)")
start = flag.Int64("start", 0, "start index (resume point)")
size = flag.Int("size", 4096, "bytes per object")
concurrency = flag.Int("concurrency", 64, "concurrent upload workers")
secure = flag.Bool("secure", false, "use HTTPS")
)
flag.Parse()
client, err := minio.New(*endpoint, &minio.Options{
Creds: credentials.NewStaticV4(*accessKey, *secretKey, ""),
Secure: *secure,
Region: *region,
BucketLookup: minio.BucketLookupPath, // Garage needs path-style
})
if err != nil {
log.Fatalf("client init: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
total := *count - *start
if total <= 0 {
log.Fatalf("nothing to do: start=%d >= count=%d", *start, *count)
}
log.Printf("seeding bucket %q: indices [%d,%d) = %d objects of %d bytes, concurrency=%d, folders=%d",
*bucket, *start, *count, total, *size, *concurrency, len(speciesBreed))
var (
cursor = *start
done int64
errCount int64
wg sync.WaitGroup
startTime = time.Now()
)
// Progress reporter.
reportDone := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var last int64
lastT := startTime
for {
select {
case <-reportDone:
return
case now := <-ticker.C:
d := atomic.LoadInt64(&done)
cur := atomic.LoadInt64(&cursor)
instRate := float64(d-last) / now.Sub(lastT).Seconds()
last, lastT = d, now
var eta time.Duration
if instRate > 0 {
eta = time.Duration(float64(total-d)/instRate) * time.Second
}
log.Printf("progress: %d/%d (%.1f%%) | %.0f obj/s | errors=%d | next-index=%d | eta=%s",
d, total, 100*float64(d)/float64(total), instRate,
atomic.LoadInt64(&errCount), cur, eta.Round(time.Second))
}
}
}()
opts := minio.PutObjectOptions{ContentType: "application/octet-stream", DisableMultipart: true}
for w := 0; w < *concurrency; w++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
// One random, incompressible buffer per worker (Garage compresses
// per-object, so reuse is fine and avoids per-object allocation).
buf := make([]byte, *size)
r := rand.New(rand.NewSource(int64(1000 + worker)))
for j := range buf {
buf[j] = byte(r.Intn(256))
}
for {
idx := atomic.AddInt64(&cursor, 1) - 1
if idx >= *count {
return
}
if ctx.Err() != nil {
return
}
key := keyFor(idx)
var putErr error
for attempt := 0; attempt < 3; attempt++ {
_, putErr = client.PutObject(ctx, *bucket, key, bytes.NewReader(buf), int64(*size), opts)
if putErr == nil || ctx.Err() != nil {
break
}
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
}
if putErr != nil {
if n := atomic.AddInt64(&errCount, 1); n <= 10 {
log.Printf("put %q failed: %v", key, putErr)
}
continue
}
atomic.AddInt64(&done, 1)
}
}(w)
}
wg.Wait()
close(reportDone)
elapsed := time.Since(startTime)
d := atomic.LoadInt64(&done)
log.Printf("DONE: uploaded %d/%d objects in %s (%.0f obj/s), errors=%d",
d, total, elapsed.Round(time.Second), float64(d)/elapsed.Seconds(), atomic.LoadInt64(&errCount))
if ctx.Err() != nil {
log.Printf("interrupted; resume with -start=%d", atomic.LoadInt64(&cursor))
}
if atomic.LoadInt64(&errCount) > 0 {
os.Exit(1)
}
}
+20 -18
View File
@@ -6,12 +6,11 @@ require (
github.com/Noooste/azuretls-client v1.13.2
github.com/Noooste/swagger v1.2.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/gofiber/fiber/v3 v3.3.0
github.com/gofiber/fiber/v3 v3.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.1.0
github.com/pelletier/go-toml/v2 v2.3.1
github.com/rs/zerolog v1.35.1
github.com/minio/minio-go/v7 v7.0.100
github.com/rs/zerolog v1.35.0
github.com/spf13/viper v1.21.0
github.com/swaggo/swag v1.16.6
golang.org/x/oauth2 v0.36.0
@@ -26,11 +25,10 @@ require (
github.com/Noooste/websocket v1.0.3 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gaukas/clienthellod v0.4.2 // indirect
github.com/gaukas/godicttls v0.0.4 // indirect
github.com/go-ini/ini v1.67.0 // indirect
@@ -38,6 +36,7 @@ require (
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag v0.26.0 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
@@ -47,20 +46,24 @@ require (
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.6 // indirect
github.com/gofiber/utils/v2 v2.0.3 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
@@ -68,15 +71,14 @@ require (
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.71.0 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+100 -52
View File
@@ -2,6 +2,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Noooste/azuretls-client v1.12.11 h1:8IvtfPf+K6wOqiRROL/APGkxQCO/+jyjH0S39rnItfQ=
github.com/Noooste/azuretls-client v1.12.11/go.mod h1:lvXW8wpaOwrwtDrSt8nv/Dd8NAbCMVNRoU4sFrAaxYs=
github.com/Noooste/azuretls-client v1.13.2 h1:8Dli5aKP5O6qN/FNSGFVkpQ1V1F1gGawAkLIE2Nrk+U=
github.com/Noooste/azuretls-client v1.13.2/go.mod h1:ON+SmiBm4Zy5vAhJmBNZk61Y7nqf4iM/b1MC1lN47Bk=
github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs=
@@ -10,62 +12,80 @@ github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FY
github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE=
github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk=
github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM=
github.com/Noooste/uquic-go v1.0.3 h1:VP8npQmU4lkVLm9Ug5Q18SJ8ExFDfUZIzd13YjYaLHE=
github.com/Noooste/uquic-go v1.0.3/go.mod h1:MxkrvgpNcbIOSQxqglC3e/798O/6zuL3mBhlFN+04w4=
github.com/Noooste/uquic-go v1.0.5 h1:HWfrxhxgB1a9Y2Au5mfFs2Y5Dy13OQIwa86D/kULPtE=
github.com/Noooste/uquic-go v1.0.5/go.mod h1:1y+qiy23PqLKudi4kQiJ0b3zXXYcyctEBRfZPTuyBz4=
github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88=
github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
github.com/Noooste/utls v1.3.21 h1:5yEzTibikzF0/d0REfbjXURGHxJDKCrRghVAU/OQBko=
github.com/Noooste/utls v1.3.21/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA=
github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
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/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE=
github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA=
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
@@ -74,40 +94,43 @@ github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFu
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
github.com/gofiber/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8=
github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA=
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/gofiber/utils/v2 v2.0.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI=
github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs=
github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ=
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
@@ -116,48 +139,63 @@ 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/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -174,68 +212,78 @@ github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
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.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+23
View File
@@ -0,0 +1,23 @@
// Package apierr translates upstream (Garage Admin API, S3/MinIO) errors into
// backend API error responses with correct HTTP status codes and stable codes.
package apierr
import "fmt"
// UpstreamError is a typed error describing a failure reported by an upstream
// service. Returned from the services layer and consumed by handlers via Map
// or Respond.
type UpstreamError struct {
HTTPStatus int
Code string // upstream code, e.g. "BucketNotEmpty", "NoSuchKey"
Message string // human-readable upstream message
Source string // "garage" or "s3"
Details map[string]string // optional: region, path, bucket, key
}
func (e *UpstreamError) Error() string {
if e.Code == "" {
return fmt.Sprintf("%s: %s", e.Source, e.Message)
}
return fmt.Sprintf("%s %s: %s", e.Source, e.Code, e.Message)
}
+40
View File
@@ -0,0 +1,40 @@
package apierr
import (
"errors"
"testing"
)
func TestUpstreamError_Error(t *testing.T) {
e := &UpstreamError{
HTTPStatus: 409,
Code: "BucketNotEmpty",
Message: "Tried to delete a non-empty bucket",
Source: "garage",
}
got := e.Error()
want := "garage BucketNotEmpty: Tried to delete a non-empty bucket"
if got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
}
func TestUpstreamError_ErrorWithoutCode(t *testing.T) {
e := &UpstreamError{HTTPStatus: 500, Source: "garage", Message: "server went boom"}
got := e.Error()
want := "garage: server went boom"
if got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
}
func TestUpstreamError_ErrorsAs(t *testing.T) {
var base error = &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Source: "garage"}
var target *UpstreamError
if !errors.As(base, &target) {
t.Fatal("errors.As should have matched *UpstreamError")
}
if target.Code != "NoSuchBucket" {
t.Fatalf("Code = %q, want NoSuchBucket", target.Code)
}
}
+59
View File
@@ -0,0 +1,59 @@
package apierr
import (
"errors"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// codeEntry defines how an upstream code translates to the API surface.
type codeEntry struct {
HTTPStatus int
APICode string
}
// upstreamCodeTable maps upstream codes (Garage + S3 share the same naming for
// most codes) to (httpStatus, apiCode). Extend here as new upstream codes are
// discovered.
var upstreamCodeTable = map[string]codeEntry{
"BucketNotEmpty": {409, models.ErrCodeBucketNotEmpty},
"NoSuchBucket": {404, models.ErrCodeBucketNotFound},
"BucketAlreadyExists": {409, models.ErrCodeBucketExists},
"BucketAlreadyOwnedByYou": {409, models.ErrCodeBucketExists},
"NoSuchKey": {404, models.ErrCodeObjectNotFound},
"InvalidBucketName": {400, models.ErrCodeInvalidBucketName},
"AccessDenied": {403, models.ErrCodeForbidden},
}
// Map translates an error into (httpStatus, apiCode, message) for the API
// response. Falls back to 500 / INTERNAL_ERROR for non-UpstreamError values.
func Map(err error) (int, string, string) {
var ue *UpstreamError
if !errors.As(err, &ue) {
return 500, models.ErrCodeInternalError, err.Error()
}
if entry, ok := upstreamCodeTable[ue.Code]; ok {
return entry.HTTPStatus, entry.APICode, ue.Message
}
status := ue.HTTPStatus
if status == 0 {
status = 500
}
msg := ue.Message
if msg == "" {
msg = ue.Error()
}
return status, models.ErrCodeInternalError, msg
}
// Respond writes the mapped error as a Fiber JSON response using the standard
// APIResponse envelope. Handlers should call this from their err != nil
// branches for all upstream failures.
func Respond(c fiber.Ctx, err error) error {
status, code, msg := Map(err)
return c.Status(status).JSON(models.ErrorResponse(code, msg))
}
+104
View File
@@ -0,0 +1,104 @@
package apierr
import (
"errors"
"testing"
"Noooste/garage-ui/internal/models"
)
func TestMap_TableDriven(t *testing.T) {
cases := []struct {
name string
err error
wantStatus int
wantCode string
wantMsg string
}{
{
name: "BucketNotEmpty",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketNotEmpty", Message: "Tried to delete a non-empty bucket", Source: "garage"},
wantStatus: 409,
wantCode: models.ErrCodeBucketNotEmpty,
wantMsg: "Tried to delete a non-empty bucket",
},
{
name: "NoSuchBucket",
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Message: "missing", Source: "s3"},
wantStatus: 404,
wantCode: models.ErrCodeBucketNotFound,
wantMsg: "missing",
},
{
name: "BucketAlreadyExists",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyExists", Message: "dup", Source: "s3"},
wantStatus: 409,
wantCode: models.ErrCodeBucketExists,
wantMsg: "dup",
},
{
name: "BucketAlreadyOwnedByYou",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyOwnedByYou", Message: "yours", Source: "s3"},
wantStatus: 409,
wantCode: models.ErrCodeBucketExists,
wantMsg: "yours",
},
{
name: "NoSuchKey",
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchKey", Message: "gone", Source: "s3"},
wantStatus: 404,
wantCode: models.ErrCodeObjectNotFound,
wantMsg: "gone",
},
{
name: "InvalidBucketName",
err: &UpstreamError{HTTPStatus: 400, Code: "InvalidBucketName", Message: "bad", Source: "s3"},
wantStatus: 400,
wantCode: models.ErrCodeInvalidBucketName,
wantMsg: "bad",
},
{
name: "AccessDenied",
err: &UpstreamError{HTTPStatus: 403, Code: "AccessDenied", Message: "nope", Source: "garage"},
wantStatus: 403,
wantCode: models.ErrCodeForbidden,
wantMsg: "nope",
},
{
name: "UnknownCodeWithStatus",
err: &UpstreamError{HTTPStatus: 503, Code: "SomethingWeird", Message: "weird", Source: "garage"},
wantStatus: 503,
wantCode: models.ErrCodeInternalError,
wantMsg: "weird",
},
{
name: "UnknownCodeNoStatus",
err: &UpstreamError{HTTPStatus: 0, Code: "", Message: "boom", Source: "garage"},
wantStatus: 500,
wantCode: models.ErrCodeInternalError,
wantMsg: "boom",
},
{
name: "NonUpstreamError",
err: errors.New("plain"),
wantStatus: 500,
wantCode: models.ErrCodeInternalError,
wantMsg: "plain",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, code, msg := Map(tc.err)
if status != tc.wantStatus {
t.Errorf("status = %d, want %d", status, tc.wantStatus)
}
if code != tc.wantCode {
t.Errorf("code = %q, want %q", code, tc.wantCode)
}
if msg != tc.wantMsg {
t.Errorf("msg = %q, want %q", msg, tc.wantMsg)
}
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package apierr
import (
"encoding/json"
"errors"
"io"
"github.com/Noooste/azuretls-client"
"github.com/minio/minio-go/v7"
)
// garageErrBody mirrors Garage's JSON error envelope.
type garageErrBody struct {
Code string `json:"code"`
Message string `json:"message"`
Region string `json:"region"`
Path string `json:"path"`
}
// ParseGarage returns nil for 2xx responses. For non-2xx it reads the body
// once, JSON-decodes the structured Garage error envelope, and returns a
// *UpstreamError. Malformed bodies are preserved verbatim in Message.
//
// ParseGarage DOES NOT close resp.RawBody on the success path — callers that
// decode the success body (decodeResponse in services/admin.go) still need
// access and are responsible for closing. On the error path the body is fully
// consumed before return.
func ParseGarage(resp *azuretls.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
bodyBytes, _ := io.ReadAll(resp.RawBody)
ue := &UpstreamError{
HTTPStatus: resp.StatusCode,
Source: "garage",
}
var parsed garageErrBody
if len(bodyBytes) > 0 && json.Unmarshal(bodyBytes, &parsed) == nil && parsed.Code != "" {
ue.Code = parsed.Code
ue.Message = parsed.Message
ue.Details = map[string]string{}
if parsed.Region != "" {
ue.Details["region"] = parsed.Region
}
if parsed.Path != "" {
ue.Details["path"] = parsed.Path
}
} else {
ue.Message = string(bodyBytes)
}
return ue
}
// FromMinio converts a MinIO/S3 error into an *UpstreamError. Returns nil when
// err is nil. If err is not a minio.ErrorResponse (e.g. a network error), a
// generic 500 *UpstreamError is returned with the raw error string as Message.
func FromMinio(err error) *UpstreamError {
if err == nil {
return nil
}
var mer minio.ErrorResponse
if errors.As(err, &mer) {
details := map[string]string{}
if mer.BucketName != "" {
details["bucket"] = mer.BucketName
}
if mer.Key != "" {
details["key"] = mer.Key
}
status := mer.StatusCode
if status == 0 {
status = 500
}
return &UpstreamError{
HTTPStatus: status,
Code: mer.Code,
Message: mer.Message,
Source: "s3",
Details: details,
}
}
return &UpstreamError{
HTTPStatus: 500,
Source: "s3",
Message: err.Error(),
}
}
+134
View File
@@ -0,0 +1,134 @@
package apierr
import (
"bytes"
"errors"
"io"
"strings"
"testing"
"github.com/Noooste/azuretls-client"
"github.com/minio/minio-go/v7"
)
func fakeResp(status int, body string) *azuretls.Response {
return &azuretls.Response{
StatusCode: status,
RawBody: io.NopCloser(bytes.NewBufferString(body)),
}
}
func TestParseGarage_Success(t *testing.T) {
resp := fakeResp(200, `{"foo":"bar"}`)
if err := ParseGarage(resp); err != nil {
t.Fatalf("ParseGarage(2xx) returned %v, want nil", err)
}
}
func TestParseGarage_StructuredError(t *testing.T) {
body := `{"code":"BucketNotEmpty","message":"Tried to delete a non-empty bucket","region":"eu-west-1","path":"/v2/DeleteBucket"}`
resp := fakeResp(409, body)
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 409 {
t.Errorf("HTTPStatus = %d, want 409", ue.HTTPStatus)
}
if ue.Code != "BucketNotEmpty" {
t.Errorf("Code = %q, want BucketNotEmpty", ue.Code)
}
if ue.Message != "Tried to delete a non-empty bucket" {
t.Errorf("Message = %q", ue.Message)
}
if ue.Source != "garage" {
t.Errorf("Source = %q, want garage", ue.Source)
}
if ue.Details["region"] != "eu-west-1" || ue.Details["path"] != "/v2/DeleteBucket" {
t.Errorf("Details = %v", ue.Details)
}
}
func TestParseGarage_MalformedBody(t *testing.T) {
resp := fakeResp(500, "not json at all")
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 500 || ue.Code != "" {
t.Errorf("HTTPStatus=%d Code=%q, want 500 and empty code", ue.HTTPStatus, ue.Code)
}
if !strings.Contains(ue.Message, "not json at all") {
t.Errorf("Message = %q, expected raw body", ue.Message)
}
}
func TestParseGarage_EmptyBody(t *testing.T) {
resp := fakeResp(502, "")
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 502 {
t.Errorf("HTTPStatus = %d, want 502", ue.HTTPStatus)
}
}
func TestFromMinio_NilInput(t *testing.T) {
if got := FromMinio(nil); got != nil {
t.Fatalf("FromMinio(nil) = %v, want nil", got)
}
}
func TestFromMinio_MinioErrorResponse(t *testing.T) {
in := minio.ErrorResponse{
StatusCode: 404,
Code: "NoSuchBucket",
Message: "The specified bucket does not exist",
BucketName: "missing",
}
got := FromMinio(in)
if got == nil {
t.Fatal("FromMinio returned nil")
}
if got.HTTPStatus != 404 {
t.Errorf("HTTPStatus = %d, want 404", got.HTTPStatus)
}
if got.Code != "NoSuchBucket" {
t.Errorf("Code = %q, want NoSuchBucket", got.Code)
}
if got.Message != "The specified bucket does not exist" {
t.Errorf("Message = %q", got.Message)
}
if got.Source != "s3" {
t.Errorf("Source = %q, want s3", got.Source)
}
if got.Details["bucket"] != "missing" {
t.Errorf("Details[bucket] = %q, want missing", got.Details["bucket"])
}
}
func TestFromMinio_NonMinioError(t *testing.T) {
got := FromMinio(errors.New("connection refused"))
if got == nil {
t.Fatal("FromMinio returned nil")
}
if got.HTTPStatus != 500 {
t.Errorf("HTTPStatus = %d, want 500", got.HTTPStatus)
}
if got.Code != "" {
t.Errorf("Code = %q, want empty", got.Code)
}
if !strings.Contains(got.Message, "connection refused") {
t.Errorf("Message = %q", got.Message)
}
if got.Source != "s3" {
t.Errorf("Source = %q, want s3", got.Source)
}
}
+16 -91
View File
@@ -4,11 +4,9 @@ import (
"Noooste/garage-ui/pkg/logger"
"context"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"Noooste/garage-ui/internal/config"
@@ -24,18 +22,15 @@ type Service struct {
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
oidcClient *http.Client
jwtService *JWTService
}
// UserInfo represents authenticated user information
type UserInfo struct {
Username string
Email string
Name string
Roles []string
Teams []string // raw team claim values (team_attribute_path), OIDC only
AuthMethod string // "oidc" | "admin" | "token"; "" on legacy sessions
Username string
Email string
Name string
Roles []string
}
// NewAuthService creates a new authentication service
@@ -63,15 +58,7 @@ func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig)
// initOIDC initializes the OIDC provider and configuration
func (a *Service) initOIDC() error {
if a.authConfig.OIDC.TLSSkipVerify {
a.oidcClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
}
ctx := a.oidcContext(context.Background())
ctx := context.Background()
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
@@ -105,13 +92,6 @@ func (a *Service) initOIDC() error {
return nil
}
func (a *Service) oidcContext(ctx context.Context) context.Context {
if a.oidcClient == nil {
return ctx
}
return oidc.ClientContext(ctx, a.oidcClient)
}
// ValidateBasicAuth validates basic authentication credentials
func (a *Service) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
@@ -143,7 +123,7 @@ func (a *Service) ExchangeCode(ctx context.Context, code string) (*oauth2.Token,
return nil, fmt.Errorf("OIDC not initialized")
}
token, err := a.oauth2Config.Exchange(a.oidcContext(ctx), code)
token, err := a.oauth2Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
@@ -158,7 +138,7 @@ func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserIn
}
// Verify the ID token
idToken, err := a.oidcVerifier.Verify(a.oidcContext(ctx), rawIDToken)
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
@@ -181,10 +161,6 @@ func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserIn
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
if a.authConfig.OIDC.TeamAttributePath != "" {
userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
}
return userInfo, nil
}
@@ -194,8 +170,6 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
return nil, fmt.Errorf("OIDC not initialized")
}
ctx = a.oidcContext(ctx)
// Create OAuth2 token source
tokenSource := a.oauth2Config.TokenSource(ctx, token)
@@ -225,10 +199,6 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
if a.authConfig.OIDC.TeamAttributePath != "" {
userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
}
return userInfo, nil
}
@@ -262,45 +232,15 @@ func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
// ExtractTeamsFromAccessToken parses the access token JWT payload and extracts
// team claim values using the configured team_attribute_path. Same rationale
// as ExtractRolesFromAccessToken: Keycloak-style IdPs often emit group claims
// only in the access token, which came from a verified code exchange.
func (a *Service) ExtractTeamsFromAccessToken(accessToken string) []string {
if accessToken == "" || a.authConfig.OIDC.TeamAttributePath == "" {
return nil
}
parts := strings.Split(accessToken, ".")
if len(parts) < 2 {
return nil
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil
}
return extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
}
// IsAdmin checks if the user has any of the configured admin roles.
// IsAdmin checks if the user has admin role
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
if len(adminRoles) == 0 {
if a.authConfig.OIDC.AdminRole == "" {
return false
}
for _, role := range userInfo.Roles {
for _, adminRole := range adminRoles {
if role == adminRole {
return true
}
if role == a.authConfig.OIDC.AdminRole {
return true
}
}
@@ -359,21 +299,8 @@ func extractRoles(claims map[string]interface{}, path string) []string {
return nil
}
// extractStringArray converts an interface{} to []string if possible.
//
// A scalar string is treated as a single-element list: IdPs commonly emit a
// single role as a bare string (e.g. "garage_role": "garage-ui-admin") rather
// than a one-element array, and discarding it would make admin_role checks
// fail with a spurious 403, see https://github.com/Noooste/garage-ui/issues/75
// extractStringArray converts an interface{} to []string if possible
func extractStringArray(value interface{}) []string {
// Try a scalar string (single role emitted as a bare value)
if str, ok := value.(string); ok {
if str == "" {
return nil
}
return []string{str}
}
// Try direct string array
if strArray, ok := value.([]string); ok {
return strArray
@@ -425,11 +352,9 @@ func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
}
return &UserInfo{
Username: claims.Username,
Email: claims.Email,
Name: claims.Name,
Roles: claims.Roles,
Teams: claims.Teams,
AuthMethod: claims.AuthMethod,
Username: claims.Username,
Email: claims.Email,
Name: claims.Name,
Roles: claims.Roles,
}, nil
}
-75
View File
@@ -1,75 +0,0 @@
package auth
import (
"testing"
"Noooste/garage-ui/internal/config"
)
func TestSessionTokenRoundTripsTeamsAndMethod(t *testing.T) {
svc, err := NewAuthService(&config.AuthConfig{}, &config.ServerConfig{})
if err != nil {
t.Fatal(err)
}
in := &UserInfo{
Username: "alice",
Email: "alice@example.com",
Teams: []string{"garage-team-backend", "garage-team-data"},
AuthMethod: "oidc",
}
token, err := svc.GenerateSessionToken(in)
if err != nil {
t.Fatal(err)
}
out, err := svc.ValidateSessionToken(token)
if err != nil {
t.Fatal(err)
}
if len(out.Teams) != 2 || out.Teams[0] != "garage-team-backend" {
t.Errorf("Teams = %v, want round-trip", out.Teams)
}
if out.AuthMethod != "oidc" {
t.Errorf("AuthMethod = %q, want oidc", out.AuthMethod)
}
}
func TestExtractTeamsFromAccessToken(t *testing.T) {
svc, err := NewAuthService(&config.AuthConfig{
OIDC: config.OIDCConfig{TeamAttributePath: "groups"},
}, &config.ServerConfig{})
if err != nil {
t.Fatal(err)
}
// Unsigned JWT with {"groups":["team-a","team-b"]} payload. Extraction
// parses claims without verifying (token came from a verified exchange).
// header {"alg":"none"} / payload base64url of {"groups":["team-a","team-b"]}
tok := "eyJhbGciOiJub25lIn0.eyJncm91cHMiOlsidGVhbS1hIiwidGVhbS1iIl19.x"
got := svc.ExtractTeamsFromAccessToken(tok)
if len(got) != 2 || got[0] != "team-a" || got[1] != "team-b" {
t.Errorf("ExtractTeamsFromAccessToken = %v, want [team-a team-b]", got)
}
if got := svc.ExtractTeamsFromAccessToken(""); got != nil {
t.Errorf("empty token should return nil, got %v", got)
}
}
func TestExtractTeamsFromAccessToken_Malformed(t *testing.T) {
svc, err := NewAuthService(&config.AuthConfig{
OIDC: config.OIDCConfig{TeamAttributePath: "groups"},
}, &config.ServerConfig{})
if err != nil {
t.Fatal(err)
}
// Fewer than two dot-separated segments.
if got := svc.ExtractTeamsFromAccessToken("single-segment"); got != nil {
t.Errorf("one-segment token = %v, want nil", got)
}
// Correct shape but the payload segment is not valid base64url.
if got := svc.ExtractTeamsFromAccessToken("hdr.!!!not-base64!!!.sig"); got != nil {
t.Errorf("bad base64 payload = %v, want nil", got)
}
// Valid base64url ("bm90anNvbg" -> "notjson") but not JSON.
if got := svc.ExtractTeamsFromAccessToken("hdr.bm90anNvbg.sig"); got != nil {
t.Errorf("non-JSON payload = %v, want nil", got)
}
}
+13 -125
View File
@@ -268,86 +268,6 @@ func TestNewAuthService_OIDCEnabled_DiscoversProvider(t *testing.T) {
}
}
// newTLSDiscoveryServer is the same as newDiscoveryServer but serves the OIDC
// discovery document over HTTPS using httptest's self-signed certificate. The
// cert is not signed by any system-trusted CA, so any HTTP client without
// InsecureSkipVerify (or the cert pinned) will fail to connect.
func newTLSDiscoveryServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
doc := map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/auth",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"id_token_signing_alg_values_supported": []string{"RS256", "EdDSA"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"keys":[]}`))
})
srv = httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestNewAuthService_OIDCEnabled_SelfSignedIssuer_FailsWithoutTLSSkipVerify(t *testing.T) {
disco := newTLSDiscoveryServer(t)
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: disco.URL,
Scopes: []string{"openid"},
TLSSkipVerify: false,
},
}
srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"}
_, err := NewAuthService(authCfg, srvCfg)
if err == nil {
t.Fatal("expected TLS verification error from self-signed issuer, got nil")
}
if !strings.Contains(err.Error(), "failed to initialize OIDC") {
t.Errorf("expected wrapping error, got %v", err)
}
}
func TestNewAuthService_OIDCEnabled_SelfSignedIssuer_SucceedsWithTLSSkipVerify(t *testing.T) {
disco := newTLSDiscoveryServer(t)
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: disco.URL,
Scopes: []string{"openid"},
TLSSkipVerify: true,
},
}
srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"}
svc, err := NewAuthService(authCfg, srvCfg)
if err != nil {
t.Fatalf("NewAuthService with tls_skip_verify=true should succeed: %v", err)
}
if svc.oidcProvider == nil {
t.Fatal("oidcProvider not initialized")
}
if svc.oidcClient == nil {
t.Fatal("oidcClient should be set when tls_skip_verify=true")
}
}
func TestNewAuthService_OIDCEnabled_BadIssuerURLReturnsError(t *testing.T) {
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
@@ -564,11 +484,8 @@ func TestExtractRolesFromAccessToken_IntermediateNodeNotMap(t *testing.T) {
}
}
func TestExtractRolesFromAccessToken_ScalarStringRoleReturnsSingleElement(t *testing.T) {
// A role_attribute_path that resolves to a scalar string (common when an IdP
// emits a single role, e.g. "garage_role": "garage-ui-admin") must be treated
// as a one-element role list, not silently discarded. Discarding it caused
// admin_role (singular) + scalar claim to yield roles=[] and a spurious 403.
func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
// Final value is a plain string, not an array — extractStringArray returns nil.
tok := makeAccessToken(t, map[string]any{
"roles": "admin",
})
@@ -577,25 +494,8 @@ func TestExtractRolesFromAccessToken_ScalarStringRoleReturnsSingleElement(t *tes
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
},
}
got := svc.ExtractRolesFromAccessToken(tok)
if len(got) != 1 || got[0] != "admin" {
t.Errorf("got %v, want [admin]", got)
}
}
func TestExtractRolesFromAccessToken_EmptyScalarStringReturnsNil(t *testing.T) {
// An empty scalar must not produce a [""] role, which would never match a
// configured admin role and only muddies logs.
tok := makeAccessToken(t, map[string]any{
"roles": "",
})
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
},
}
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
t.Errorf("expected nil for empty scalar role, got %v", got)
t.Errorf("expected nil for non-array roles, got %v", got)
}
}
@@ -629,35 +529,23 @@ func TestExtractRolesFromAccessToken_BadJSONInPayload(t *testing.T) {
func TestIsAdmin(t *testing.T) {
tests := []struct {
name string
adminRole string
adminRoles []string
userRoles []string
want bool
name string
adminRole string
userRoles []string
want bool
}{
{"empty admin role config returns false", "", nil, []string{"admin"}, false},
{"user has admin role", "admin", nil, []string{"viewer", "admin"}, true},
{"user lacks admin role", "admin", nil, []string{"viewer"}, false},
{"user has no roles", "admin", nil, nil, false},
{"role match is exact (case-sensitive)", "admin", nil, []string{"Admin"}, false},
// admin_roles list
{"user matches first entry in admin_roles", "", []string{"group1", "group2"}, []string{"group1"}, true},
{"user matches second entry in admin_roles", "", []string{"group1", "group2"}, []string{"group2"}, true},
{"user matches none of admin_roles", "", []string{"group1", "group2"}, []string{"group3"}, false},
{"empty admin_roles list returns false", "", []string{}, []string{"group1"}, false},
// admin_role + admin_roles merge
{"matches single admin_role when admin_roles set too", "admin", []string{"ops"}, []string{"admin"}, true},
{"matches admin_roles entry when admin_role set too", "admin", []string{"ops"}, []string{"ops"}, true},
{"matches neither admin_role nor admin_roles", "admin", []string{"ops"}, []string{"viewer"}, false},
{"empty admin role config returns false", "", []string{"admin"}, false},
{"user has admin role", "admin", []string{"viewer", "admin"}, true},
{"user lacks admin role", "admin", []string{"viewer"}, false},
{"user has no roles", "admin", nil, false},
{"role match is exact (case-sensitive)", "admin", []string{"Admin"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles},
OIDC: config.OIDCConfig{AdminRole: tc.adminRole},
},
}
if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want {
+8 -12
View File
@@ -31,12 +31,10 @@ type StateData struct {
}
type SessionClaims struct {
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"roles"`
Teams []string `json:"teams,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
@@ -162,12 +160,10 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
claims := SessionClaims{
Username: userInfo.Username,
Email: userInfo.Email,
Name: userInfo.Name,
Roles: userInfo.Roles,
Teams: userInfo.Teams,
AuthMethod: userInfo.AuthMethod,
Username: userInfo.Username,
Email: userInfo.Email,
Name: userInfo.Name,
Roles: userInfo.Roles,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
-89
View File
@@ -1,89 +0,0 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// PreviewTokenLocalsKey is the fiber Locals key the auth middleware sets
// after validating a preview token. The authz middleware reads it to
// authorize the request without a subject.
const PreviewTokenLocalsKey = "previewTokenClaims"
// PreviewClaims identify the single object a preview token can read.
type PreviewClaims struct {
Bucket string `json:"b"`
Key string `json:"k"`
ExpiresAt int64 `json:"exp"`
}
// previewSecret derives the HMAC key from the JWT signing key. A configured
// session key therefore keeps preview URLs valid across restarts, and a
// generated key invalidates them on restart, which the frontend recovers
// from by minting a fresh URL.
func (j *JWTService) previewSecret() []byte {
j.mu.RLock()
defer j.mu.RUnlock()
sum := sha256.Sum256(append([]byte("garage-ui-preview-token:"), j.privateKey...))
return sum[:]
}
// MintPreviewToken signs a token granting read access to one exact object
// until the TTL elapses.
func (a *Service) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
expiresAt := time.Now().Add(ttl)
token, err := mintPreviewToken(a.jwtService.previewSecret(), bucket, key, expiresAt)
return token, expiresAt, err
}
// ValidatePreviewToken checks the signature, expiry, and exact object match.
func (a *Service) ValidatePreviewToken(token, bucket, key string) error {
return verifyPreviewToken(a.jwtService.previewSecret(), token, bucket, key, time.Now())
}
func mintPreviewToken(secret []byte, bucket, key string, expiresAt time.Time) (string, error) {
payload, err := json.Marshal(PreviewClaims{Bucket: bucket, Key: key, ExpiresAt: expiresAt.Unix()})
// Defensive and unreachable: marshaling a struct of two strings and an int64
// cannot fail, so this branch stays uncovered by design.
if err != nil {
return "", fmt.Errorf("failed to encode preview claims: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
return encoded + "." + signPreview(secret, encoded), nil
}
func verifyPreviewToken(secret []byte, token, bucket, key string, now time.Time) error {
encoded, sig, ok := strings.Cut(token, ".")
if !ok {
return fmt.Errorf("malformed preview token")
}
if !hmac.Equal([]byte(sig), []byte(signPreview(secret, encoded))) {
return fmt.Errorf("invalid preview token signature")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return fmt.Errorf("malformed preview token payload")
}
var claims PreviewClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return fmt.Errorf("malformed preview token claims")
}
if now.Unix() > claims.ExpiresAt {
return fmt.Errorf("preview token expired")
}
if claims.Bucket != bucket || claims.Key != key {
return fmt.Errorf("preview token does not match the requested object")
}
return nil
}
func signPreview(secret []byte, encoded string) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(encoded))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
-122
View File
@@ -1,122 +0,0 @@
package auth
import (
"encoding/base64"
"strings"
"testing"
"time"
)
func newPreviewTestService(t *testing.T) *Service {
t.Helper()
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
return &Service{jwtService: jwtSvc}
}
func TestPreviewToken_RoundTrip(t *testing.T) {
svc := newPreviewTestService(t)
token, expiresAt, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if remaining := time.Until(expiresAt); remaining < 59*time.Minute || remaining > time.Hour {
t.Errorf("expiresAt %v is not about an hour away", expiresAt)
}
if err := svc.ValidatePreviewToken(token, "b1", "dir/clip.mp4"); err != nil {
t.Errorf("ValidatePreviewToken: %v", err)
}
}
func TestPreviewToken_Expired(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k", -time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected expired token to be rejected")
}
}
func TestPreviewToken_WrongObject(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k1", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := svc.ValidatePreviewToken(token, "b2", "k1"); err == nil {
t.Error("expected wrong bucket to be rejected")
}
if err := svc.ValidatePreviewToken(token, "b1", "k2"); err == nil {
t.Error("expected wrong key to be rejected")
}
}
func TestPreviewToken_Tampered(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
payload, sig, _ := strings.Cut(token, ".")
flipped := "A" + payload[1:]
if flipped == payload {
flipped = "B" + payload[1:]
}
if err := svc.ValidatePreviewToken(flipped+"."+sig, "b1", "k"); err == nil {
t.Error("expected tampered payload to be rejected")
}
if err := svc.ValidatePreviewToken(payload+".AAAA", "b1", "k"); err == nil {
t.Error("expected tampered signature to be rejected")
}
}
func TestPreviewToken_Malformed(t *testing.T) {
svc := newPreviewTestService(t)
for _, tok := range []string{"", "nodot", "a.b.c", "!!!.???", "bm90anNvbg.sig"} {
if err := svc.ValidatePreviewToken(tok, "b1", "k"); err == nil {
t.Errorf("expected malformed token %q to be rejected", tok)
}
}
}
func TestPreviewToken_DifferentServicesRejectEachOther(t *testing.T) {
a := newPreviewTestService(t)
b := newPreviewTestService(t)
token, _, err := a.MintPreviewToken("b1", "k", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := b.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a token from another key to be rejected")
}
}
// TestPreviewToken_ValidSignatureMalformedBase64Payload signs a payload that
// is not valid RawURLEncoding, so the signature check passes but the base64
// decode fails. This exercises the decode error branch in verifyPreviewToken.
func TestPreviewToken_ValidSignatureMalformedBase64Payload(t *testing.T) {
svc := newPreviewTestService(t)
secret := svc.jwtService.previewSecret()
enc := "!!not-base64!!"
token := enc + "." + signPreview(secret, enc)
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a validly signed but non-base64 payload to be rejected")
}
}
// TestPreviewToken_ValidSignatureNonJSONPayload signs a valid base64 payload
// whose bytes are not JSON, so the signature and decode both pass but the
// unmarshal fails. This exercises the JSON error branch in verifyPreviewToken.
func TestPreviewToken_ValidSignatureNonJSONPayload(t *testing.T) {
svc := newPreviewTestService(t)
secret := svc.jwtService.previewSecret()
enc := base64.RawURLEncoding.EncodeToString([]byte("not json"))
token := enc + "." + signPreview(secret, enc)
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a validly signed but non-JSON payload to be rejected")
}
}
-29
View File
@@ -1,29 +0,0 @@
package auth
import (
"crypto/subtle"
"testing"
)
func TestValidateAdminToken(t *testing.T) {
tests := []struct {
name string
configured string
provided string
want bool
}{
{"correct token", "my-secret-token", "my-secret-token", true},
{"wrong token", "my-secret-token", "wrong-token", false},
{"empty provided", "my-secret-token", "", false},
{"empty configured", "", "any-token", false},
{"both empty", "", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := subtle.ConstantTimeCompare([]byte(tc.configured), []byte(tc.provided)) == 1
if got != tc.want {
t.Errorf("ValidateAdminToken(%q, %q) = %v, want %v", tc.configured, tc.provided, got, tc.want)
}
})
}
}
-129
View File
@@ -1,129 +0,0 @@
package authz
import (
"sort"
"strings"
)
// Subject is who is asking: resolved once per request by the TeamResolver.
type Subject struct {
ID string
IsAdmin bool
Bindings []Binding
ClusterPerms PermSet
}
// Resource is what is being acted on. Empty Bucket means the action is
// global or unscoped (e.g. the ListBuckets endpoint itself).
type Resource struct {
Bucket string
}
// Decision is the outcome of an authorization check.
type Decision struct {
Allow bool
Reason string
}
// Authorizer decides whether a subject may perform an action on a resource.
// It is an interface so enforcement can later move to an external PDP or
// Garage-side scoped tokens without touching handlers.
type Authorizer interface {
Decide(subj Subject, action string, res Resource) Decision
}
type policyAuthorizer struct{}
// NewAuthorizer returns the built-in policy evaluator.
func NewAuthorizer() Authorizer { return policyAuthorizer{} }
func (policyAuthorizer) Decide(subj Subject, action string, res Resource) Decision {
return Decide(subj, action, res)
}
// Decide is the pure decision function. The synthetic admin subject flows
// through the same logic as any team, with no IsAdmin shortcut.
func Decide(subj Subject, action string, res Resource) Decision {
spec, ok := Vocabulary[action]
if !ok {
return Decision{Allow: false, Reason: "unknown_permission"}
}
if spec.Scope == ScopeGlobal {
if _, ok := subj.ClusterPerms[action]; ok {
return Decision{Allow: true, Reason: "cluster_permission"}
}
return Decision{Allow: false, Reason: "no_cluster_permission"}
}
// Prefix-scoped.
for _, b := range subj.Bindings {
if _, ok := b.Permissions[action]; !ok {
continue
}
// Unscoped call (list endpoint): any binding holding the permission
// suffices; per-bucket filtering happens on the response.
if res.Bucket == "" {
return Decision{Allow: true, Reason: "any_binding"}
}
if prefixesMatch(b.BucketPrefixes, res.Bucket) {
return Decision{Allow: true, Reason: "binding_match"}
}
}
return Decision{Allow: false, Reason: "no_matching_binding"}
}
func prefixesMatch(prefixes []string, bucket string) bool {
for _, p := range prefixes {
if p == "*" || strings.HasPrefix(bucket, p) {
return true
}
}
return false
}
// AdminSubject builds the synthetic admin team: one wildcard binding holding
// every prefix-scoped permission (admin-only included) plus every global
// permission. Same code path as any team.
func AdminSubject(id string) Subject {
prefixPerms := PermSet{}
clusterPerms := PermSet{}
for name, spec := range Vocabulary {
if spec.Scope == ScopePrefix {
prefixPerms[name] = struct{}{}
} else {
clusterPerms[name] = struct{}{}
}
}
return Subject{
ID: id,
IsAdmin: true,
Bindings: []Binding{{BucketPrefixes: []string{"*"}, Permissions: prefixPerms}},
ClusterPerms: clusterPerms,
}
}
// EffectivePermissions returns the sorted union of prefix-scoped permissions
// the subject holds on the named bucket. This is the value served in API responses
// so the frontend never does prefix matching. Returns nil when nothing
// matches.
func EffectivePermissions(subj Subject, bucket string) []string {
set := PermSet{}
for _, b := range subj.Bindings {
if !prefixesMatch(b.BucketPrefixes, bucket) {
continue
}
for perm := range b.Permissions {
set[perm] = struct{}{}
}
}
if len(set) == 0 {
return nil
}
out := make([]string, 0, len(set))
for perm := range set {
out = append(out, perm)
}
sort.Strings(out)
return out
}
-117
View File
@@ -1,117 +0,0 @@
package authz
import (
"sort"
"testing"
)
func testSubject() Subject {
return Subject{
ID: "alice@example.com",
Bindings: []Binding{
{BucketPrefixes: []string{"backend-"}, Permissions: PermSet{
"bucket.list": {}, "bucket.read": {}, "bucket.create": {}, "object.read": {}, "object.write": {},
}},
{BucketPrefixes: []string{"shared-"}, Permissions: PermSet{
"bucket.list": {}, "bucket.read": {}, "object.read": {},
}},
},
ClusterPerms: PermSet{"cluster.status": {}},
}
}
func TestDecidePrefixScoped(t *testing.T) {
s := testSubject()
cases := []struct {
action, bucket string
allow bool
}{
{"bucket.read", "backend-api", true},
{"bucket.read", "shared-docs", true},
{"bucket.read", "data-warehouse", false}, // no binding matches
{"object.write", "backend-api", true},
{"object.write", "shared-docs", false}, // readonly binding
{"bucket.create", "backend-new", true}, // prefix guard: new name matches
{"bucket.create", "frontend-new", false}, // prefix guard: no match
{"bucket.delete", "backend-api", false}, // permission not granted at all
}
for _, tc := range cases {
d := Decide(s, tc.action, Resource{Bucket: tc.bucket})
if d.Allow != tc.allow {
t.Errorf("Decide(%s, %s) = %v (%s), want %v", tc.action, tc.bucket, d.Allow, d.Reason, tc.allow)
}
}
}
func TestDecideUnscopedListEndpoint(t *testing.T) {
// GET /buckets carries no bucket name: allowed if ANY binding grants
// bucket.list (the response is filtered per bucket afterwards).
s := testSubject()
if d := Decide(s, "bucket.list", Resource{}); !d.Allow {
t.Errorf("bucket.list with empty resource should be allowed for a subject holding it in any binding: %s", d.Reason)
}
noList := Subject{ID: "bob", Bindings: []Binding{{BucketPrefixes: []string{"x-"}, Permissions: PermSet{"object.read": {}}}}}
if d := Decide(noList, "bucket.list", Resource{}); d.Allow {
t.Error("bucket.list should be denied when no binding grants it")
}
}
func TestDecideGlobal(t *testing.T) {
s := testSubject()
if d := Decide(s, "cluster.status", Resource{}); !d.Allow {
t.Errorf("cluster.status should be allowed: %s", d.Reason)
}
if d := Decide(s, "cluster.statistics", Resource{}); d.Allow {
t.Error("cluster.statistics should be denied")
}
if d := Decide(s, "key.create", Resource{}); d.Allow {
t.Error("admin-only key.create should be denied for a team subject")
}
}
func TestDecideUnknownPermission(t *testing.T) {
if d := Decide(testSubject(), "bucket.explode", Resource{}); d.Allow {
t.Error("unknown permission must be denied")
}
}
func TestAdminSubject(t *testing.T) {
a := AdminSubject("root")
if !a.IsAdmin {
t.Error("AdminSubject must set IsAdmin")
}
// Admin goes through the exact same Decide path, no shortcut.
for _, action := range []string{"bucket.delete", "object.write", "key.create", "key.read_secret", "cluster.layout.apply", "node.repair"} {
if d := Decide(a, action, Resource{Bucket: "any-bucket-at-all"}); !d.Allow {
t.Errorf("admin denied %s: %s", action, d.Reason)
}
}
}
func TestEffectivePermissions(t *testing.T) {
s := testSubject()
got := EffectivePermissions(s, "backend-api")
want := []string{"bucket.create", "bucket.list", "bucket.read", "object.read", "object.write"}
if !equalStrings(got, want) {
t.Errorf("EffectivePermissions(backend-api) = %v, want %v", got, want)
}
if got := EffectivePermissions(s, "data-x"); got != nil {
t.Errorf("EffectivePermissions(data-x) = %v, want nil", got)
}
admin := EffectivePermissions(AdminSubject("root"), "anything")
if !sort.StringsAreSorted(admin) || len(admin) == 0 {
t.Errorf("admin effective permissions should be all prefix-scoped perms, sorted; got %v", admin)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
-244
View File
@@ -1,244 +0,0 @@
package authz
import (
"fmt"
"reflect"
"runtime"
"strconv"
"strings"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/models"
logpkg "Noooste/garage-ui/pkg/logger"
"github.com/gofiber/fiber/v3"
)
// SubjectLocalsKey is the fiber.Ctx.Locals key carrying the resolved Subject.
const SubjectLocalsKey = "authzSubject"
// Middleware wires the policy into Fiber. When the policy is disabled
// (access_control absent) every handler is a passthrough no-op.
type Middleware struct {
enabled bool
resolver TeamResolver
authorizer Authorizer
}
func NewMiddleware(policy *Policy, resolver TeamResolver, authorizer Authorizer) *Middleware {
return &Middleware{enabled: policy.Enabled, resolver: resolver, authorizer: authorizer}
}
// Enabled reports whether access control is active.
func (m *Middleware) Enabled() bool { return m.enabled }
// ResolveSubject computes the request's Subject once, right after
// authentication. Handlers and the capabilities endpoint read the same
// struct, so enforcement and UI can never disagree.
func (m *Middleware) ResolveSubject() fiber.Handler {
return func(c fiber.Ctx) error {
if !m.enabled {
return c.Next()
}
userInfo, ok := c.Locals("userInfo").(*auth.UserInfo)
if !ok || userInfo == nil {
return c.Next() // no identity (auth disabled); Require will deny
}
c.Locals(SubjectLocalsKey, m.resolver.Resolve(userInfo))
return c.Next()
}
}
// SubjectFrom returns the Subject resolved for this request, if any.
func SubjectFrom(c fiber.Ctx) (Subject, bool) {
subj, ok := c.Locals(SubjectLocalsKey).(Subject)
return subj, ok
}
// ScopeResolver extracts the target Resource from the request.
type ScopeResolver func(c fiber.Ctx) Resource
// ScopeNone is for global permissions and unscoped list endpoints.
func ScopeNone(fiber.Ctx) Resource { return Resource{} }
// BucketFromParam reads the bucket name from a URL parameter.
func BucketFromParam(param string) ScopeResolver {
return func(c fiber.Ctx) Resource {
return Resource{Bucket: c.Params(param)}
}
}
// BucketFromBody reads the bucket name from a JSON body {"name": "..."}.
// Fiber buffers the body, so the handler can bind it again afterwards.
func BucketFromBody() ScopeResolver {
return func(c fiber.Ctx) Resource {
var req struct {
Name string `json:"name"`
}
if err := c.Bind().JSON(&req); err != nil {
return Resource{}
}
return Resource{Bucket: req.Name}
}
}
// Require gates a route on the caller holding ALL of perms for the resolved
// resource. One structured decision log line is emitted per check: denies at
// warn, allows at debug.
//
// The closure's function name is the marker VerifyRouteCoverage looks for.
// Do not wrap it in another anonymous function.
func (m *Middleware) Require(scope ScopeResolver, perms ...string) fiber.Handler {
if len(perms) == 0 {
// A no-perms Require would silently no-op (allow everything) yet still
// satisfy VerifyRouteCoverage, defeating the fail-closed guarantee.
panic("authz.Require: at least one permission required")
}
for _, p := range perms {
if !IsValidPermission(p) {
panic(fmt.Sprintf("authz.Require: unknown permission %q", p)) // programmer error, fail at wiring time
}
}
return func(c fiber.Ctx) error {
if !m.enabled {
return c.Next()
}
// A validated preview token authorizes exactly one thing: object.read
// on the object it names. The auth middleware set these claims after
// verifying the signature and the bucket and key match this request.
if claims, ok := c.Locals(auth.PreviewTokenLocalsKey).(*auth.PreviewClaims); ok && claims != nil {
if len(perms) == 1 && perms[0] == PermObjectRead && scope(c).Bucket == claims.Bucket {
logDecision(c, "preview-token", PermObjectRead, claims.Bucket, true, "preview_token")
return c.Next()
}
}
subj, ok := SubjectFrom(c)
if !ok {
logDecision(c, "", strings.Join(perms, ","), "", false, "no_subject")
return forbidden(c, perms[0])
}
res := scope(c)
for _, perm := range perms {
d := m.authorizer.Decide(subj, perm, res)
logDecision(c, subj.ID, perm, res.Bucket, d.Allow, d.Reason)
if !d.Allow {
return forbidden(c, perm)
}
}
return c.Next()
}
}
func forbidden(c fiber.Ctx, perm string) error {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Missing permission: "+perm),
)
}
func logDecision(c fiber.Ctx, subject, action, resource string, allow bool, reason string) {
l := logpkg.FromCtx(c.Context())
evt := l.Debug()
if !allow {
evt = l.Warn()
}
decision := "allow"
if !allow {
decision = "deny"
}
evt.Str("subject", subject).
Str("action", action).
Str("resource", resource).
Str("decision", decision).
Str("reason", reason).
Msg("authz_decision")
}
// coverageExemptPaths are /api/v1 routes that intentionally carry no Require:
// health is unauthenticated, capabilities is the frontend's fail-closed
// source and returns only the caller's own permissions.
var coverageExemptPaths = map[string]struct{}{
"/api/v1/health": {},
"/api/v1/capabilities": {},
}
// VerifyRouteCoverage walks the app's route table and errors if any /api/v1
// route lacks a Require handler. Called at startup (and from tests): a new
// endpoint registered without declaring its permission prevents boot instead
// of silently failing open.
//
// .Use()-registered routes need special handling. Group-level middleware
// (api.Use(handler) on the "/api/v1" group) produces synthetic per-method
// bookkeeping entries at exactly the bare prefix. Those aren't endpoints and
// are exempt. But a use-route at any deeper path (api.Use("/sneaky",
// terminalHandler)) IS a reachable endpoint and gets the same fail-closed
// treatment as normal routes.
func VerifyRouteCoverage(app *fiber.App) error {
// fiber.Route doesn't export whether a route was .Use()-registered, so
// classify by diffing GetRoutes() (everything) against GetRoutes(true)
// (use-routes filtered out): entries unmatched in the filtered multiset
// are use-registered.
nonUse := map[string]int{}
for _, r := range app.GetRoutes(true) {
nonUse[routeKey(r)]++
}
var naked []string
for _, route := range app.GetRoutes() {
// Consume the multiset for every route, before any skip, so
// classification stays consistent across the whole table.
isUse := true
if k := routeKey(route); nonUse[k] > 0 {
nonUse[k]--
isUse = false
}
if !strings.HasPrefix(route.Path, "/api/v1") {
continue
}
if isUse && route.Path == "/api/v1" {
continue // group-middleware bookkeeping at the bare prefix
}
if _, exempt := coverageExemptPaths[route.Path]; exempt {
continue
}
if route.Method == fiber.MethodHead && hasRequireForPath(app, fiber.MethodGet, route.Path) {
continue // Fiber auto-registers HEAD mirroring GET
}
if !routeHasRequire(route.Handlers) {
naked = append(naked, route.Method+" "+route.Path)
}
}
if len(naked) > 0 {
return fmt.Errorf("authz: routes without Require permission declaration: %s", strings.Join(naked, ", "))
}
return nil
}
// routeKey identifies a route for the use-route diff. Method+Path+handler
// count is robust enough: two routes sharing all three are interchangeable
// for coverage purposes, and the multiset keeps counts honest.
func routeKey(r fiber.Route) string {
return r.Method + " " + r.Path + " " + strconv.Itoa(len(r.Handlers))
}
func hasRequireForPath(app *fiber.App, method, path string) bool {
for _, route := range app.GetRoutes(true) {
if route.Method == method && route.Path == path {
return routeHasRequire(route.Handlers)
}
}
return false
}
func routeHasRequire(handlers []fiber.Handler) bool {
for _, h := range handlers {
fn := runtime.FuncForPC(fiberHandlerPC(h))
if fn != nil && strings.Contains(fn.Name(), "authz.(*Middleware).Require") {
return true
}
}
return false
}
func fiberHandlerPC(h fiber.Handler) uintptr {
return reflect.ValueOf(h).Pointer()
}
-285
View File
@@ -1,285 +0,0 @@
package authz
import (
"io"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"github.com/gofiber/fiber/v3"
)
func middlewareFixture(t *testing.T) *Middleware {
t.Helper()
policy, err := CompilePolicy(&config.AccessControlConfig{
Teams: []config.TeamConfig{{
Name: "backend",
ClaimValues: []string{"g-backend"},
Bindings: []config.BindingConfig{{
BucketPrefixes: []string{"backend-"},
Permissions: []string{"bucket.read", "bucket.create", "object.read"},
}},
ClusterPermissions: []string{"cluster.status"},
}},
})
if err != nil {
t.Fatal(err)
}
return NewMiddleware(policy, NewTeamResolver(policy, []string{"garage-admin"}), NewAuthorizer())
}
func newTestApp(m *Middleware, userInfo *auth.UserInfo) *fiber.App {
app := fiber.New()
app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware
if userInfo != nil {
c.Locals("userInfo", userInfo)
}
return c.Next()
})
app.Use(m.ResolveSubject())
app.Get("/api/v1/buckets/:name", m.Require(BucketFromParam("name"), PermBucketRead), func(c fiber.Ctx) error {
return c.SendString("ok")
})
app.Post("/api/v1/buckets", m.Require(BucketFromBody(), PermBucketCreate), func(c fiber.Ctx) error {
return c.SendString("created")
})
app.Get("/api/v1/cluster/status", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error {
return c.SendString("ok")
})
return app
}
func doReq(t *testing.T, app *fiber.App, method, path, body string) int {
t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode
}
func TestRequireAllowsMatchingTeam(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 200 {
t.Errorf("matching bucket: status %d, want 200", code)
}
if code := doReq(t, app, "GET", "/api/v1/cluster/status", ""); code != 200 {
t.Errorf("cluster.status: status %d, want 200", code)
}
}
func TestRequireDeniesOutOfScope(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "GET", "/api/v1/buckets/data-warehouse", ""); code != 403 {
t.Errorf("non-matching bucket: status %d, want 403", code)
}
}
func TestRequireBucketFromBody(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"backend-new"}`); code != 200 {
t.Errorf("create with matching prefix: status %d, want 200", code)
}
if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"other-new"}`); code != 403 {
t.Errorf("create with foreign prefix: status %d, want 403", code)
}
}
func TestRequireDefaultDenyZeroTeamUser(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "z@x", AuthMethod: "oidc"})
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 {
t.Errorf("zero-team user: status %d, want 403", code)
}
}
func TestRequirePassthroughWhenDisabled(t *testing.T) {
policy, _ := CompilePolicy(nil)
m := NewMiddleware(policy, NewTeamResolver(policy, nil), NewAuthorizer())
// No userInfo at all. Disabled access control must not require a subject.
app := newTestApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/anything", ""); code != 200 {
t.Errorf("disabled: status %d, want 200", code)
}
}
func TestMiddlewareEnabledReflectsPolicy(t *testing.T) {
if !middlewareFixture(t).Enabled() {
t.Error("Enabled() = false for a configured policy, want true")
}
disabled, _ := CompilePolicy(nil)
m := NewMiddleware(disabled, NewTeamResolver(disabled, nil), NewAuthorizer())
if m.Enabled() {
t.Error("Enabled() = true for a nil (disabled) policy, want false")
}
}
func TestResolveSubjectWithoutUserInfoDenies(t *testing.T) {
// Enabled middleware, but auth set no userInfo local: ResolveSubject leaves
// no subject, and Require then denies for want of one.
m := middlewareFixture(t)
app := newTestApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 {
t.Errorf("enabled + no userInfo: status %d, want 403", code)
}
}
func TestRequireBucketFromBodyMalformedBody(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
// Malformed JSON: BucketFromBody's bind fails and returns an empty resource.
// An empty bucket is an unscoped check, so a team holding bucket.create in
// any binding is allowed (this is what proves the resource came back empty:
// a non-empty foreign bucket name would be denied instead).
if code := doReq(t, app, "POST", "/api/v1/buckets", "{not-json"); code != 200 {
t.Errorf("malformed body: status %d, want 200 (empty resource, any_binding)", code)
}
}
func TestRequireUnknownPermissionPanics(t *testing.T) {
m := middlewareFixture(t)
defer func() {
r := recover()
if r == nil {
t.Fatal("Require with an unknown permission: want panic, got none")
}
if msg, ok := r.(string); !ok || !strings.Contains(msg, "unknown permission") {
t.Errorf("panic value = %v, want message containing %q", r, "unknown permission")
}
}()
m.Require(ScopeNone, "bogus.permission")
}
func TestVerifyRouteCoverage(t *testing.T) {
m := middlewareFixture(t)
covered := fiber.New()
covered.Get("/api/v1/capabilities", func(c fiber.Ctx) error { return nil }) // exempt
covered.Get("/api/v1/health", func(c fiber.Ctx) error { return nil }) // exempt
covered.Get("/api/v1/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil })
covered.Get("/other", func(c fiber.Ctx) error { return nil }) // outside /api/v1
if err := VerifyRouteCoverage(covered); err != nil {
t.Errorf("covered app: %v, want nil", err)
}
uncovered := fiber.New()
uncovered.Get("/api/v1/naked", func(c fiber.Ctx) error { return nil })
err := VerifyRouteCoverage(uncovered)
if err == nil {
t.Fatal("uncovered app: want error, got nil")
}
if !strings.Contains(err.Error(), "/api/v1/naked") {
t.Errorf("error should name the naked route: %v", err)
}
}
func TestVerifyRouteCoverage_GroupUseBookkeepingExempt(t *testing.T) {
// Group-level .Use() middleware produces synthetic per-method entries at
// exactly the bare group prefix; those aren't endpoints and must not trip
// the coverage check as long as the real routes carry Require.
m := middlewareFixture(t)
app := fiber.New()
api := app.Group("/api/v1")
api.Use(func(c fiber.Ctx) error { return c.Next() }) // stand-in for AuthMiddleware
api.Use(m.ResolveSubject())
api.Get("/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil })
if err := VerifyRouteCoverage(app); err != nil {
t.Errorf("group-level Use at bare prefix should be exempt: %v", err)
}
}
func TestRequireZeroPermissionsPanics(t *testing.T) {
m := middlewareFixture(t)
defer func() {
r := recover()
if r == nil {
t.Fatal("Require with zero perms: want panic, got none")
}
msg, ok := r.(string)
if !ok || !strings.Contains(msg, "at least one permission required") {
t.Errorf("panic value = %v, want message containing %q", r, "at least one permission required")
}
}()
m.Require(ScopeNone)
}
func newPreviewClaimsApp(m *Middleware, claims *auth.PreviewClaims) *fiber.App {
app := fiber.New()
app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware validating a preview token
if claims != nil {
c.Locals(auth.PreviewTokenLocalsKey, claims)
}
return c.Next()
})
app.Use(m.ResolveSubject())
app.Get("/api/v1/buckets/:bucket/objects/*", m.Require(BucketFromParam("bucket"), PermObjectRead), func(c fiber.Ctx) error {
return c.SendString("bytes")
})
app.Delete("/api/v1/buckets/:bucket/objects/*", m.Require(BucketFromParam("bucket"), PermObjectDelete), func(c fiber.Ctx) error {
return c.SendString("deleted")
})
return app
}
func TestRequirePreviewTokenBypass(t *testing.T) {
m := middlewareFixture(t)
t.Run("matching bucket allows object read without a subject", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "any-bucket", Key: "k"})
if code := doReq(t, app, "GET", "/api/v1/buckets/any-bucket/objects/k", ""); code != 200 {
t.Errorf("status = %d, want 200", code)
}
})
t.Run("bucket mismatch denies", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "bucket-a", Key: "k"})
if code := doReq(t, app, "GET", "/api/v1/buckets/bucket-b/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
t.Run("other permissions stay denied", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "any-bucket", Key: "k"})
if code := doReq(t, app, "DELETE", "/api/v1/buckets/any-bucket/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
t.Run("no claims still requires a subject", func(t *testing.T) {
app := newPreviewClaimsApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/any-bucket/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
}
func TestVerifyRouteCoverage_UseRegisteredEndpointFlagged(t *testing.T) {
// A .Use()-registered route at a DEEPER path under /api/v1 is a reachable
// endpoint (Fiber runs it for every method with that prefix); it must get
// the same fail-closed treatment as a normal route.
app := fiber.New()
api := app.Group("/api/v1")
api.Use(func(c fiber.Ctx) error { return c.Next() }) // bare-prefix middleware stays exempt
api.Use("/sneaky", func(c fiber.Ctx) error { return c.SendString("terminal") })
err := VerifyRouteCoverage(app)
if err == nil {
t.Fatal("use-registered endpoint under /api/v1: want error, got nil")
}
if !strings.Contains(err.Error(), "/api/v1/sneaky") {
t.Errorf("error should name /api/v1/sneaky: %v", err)
}
}
-212
View File
@@ -1,212 +0,0 @@
package authz
import (
"fmt"
"strings"
"Noooste/garage-ui/internal/config"
)
// PermSet is a set of concrete permission names.
type PermSet map[string]struct{}
// Binding pairs bucket-name prefixes with the prefix-scoped permissions that
// apply to buckets matching them.
type Binding struct {
BucketPrefixes []string
Permissions PermSet
}
// TeamPolicy is a compiled team: presets resolved, globs expanded, validated.
type TeamPolicy struct {
Name string
ClaimValues []string
Bindings []Binding
ClusterPerms PermSet
}
// Policy is the compiled access-control policy. Enabled=false (access_control
// absent) means "behave exactly as before this feature existed".
type Policy struct {
Enabled bool
Teams []TeamPolicy
byClaim map[string][]int // claim value -> indexes into Teams
}
const presetPrefix = "preset:"
// CompilePolicy validates and compiles the access_control config section.
// Any error here must abort startup; a half-valid policy is worse than none.
func CompilePolicy(cfg *config.AccessControlConfig) (*Policy, error) {
if cfg == nil {
return &Policy{Enabled: false}, nil
}
resolvedPresets := make(map[string]PermSet, len(cfg.Presets))
for name := range cfg.Presets {
perms, err := resolvePreset(cfg.Presets, name, nil)
if err != nil {
return nil, err
}
resolvedPresets[name] = perms
}
p := &Policy{Enabled: true, byClaim: map[string][]int{}}
seenNames := map[string]struct{}{}
for ti, team := range cfg.Teams {
if team.Name == "" {
return nil, fmt.Errorf("access_control: team %d has no name", ti)
}
if _, dup := seenNames[team.Name]; dup {
return nil, fmt.Errorf("access_control: duplicate team name %q", team.Name)
}
seenNames[team.Name] = struct{}{}
if len(team.ClaimValues) == 0 {
return nil, fmt.Errorf("access_control: team %q has empty claim_values", team.Name)
}
if len(team.Bindings) == 0 && len(team.ClusterPermissions) == 0 {
return nil, fmt.Errorf("access_control: team %q must have at least one binding or cluster permission", team.Name)
}
tp := TeamPolicy{Name: team.Name, ClaimValues: team.ClaimValues, ClusterPerms: PermSet{}}
for bi, b := range team.Bindings {
if len(b.BucketPrefixes) == 0 {
return nil, fmt.Errorf("access_control: team %q binding %d has no bucket_prefixes", team.Name, bi)
}
perms, err := resolvePermList(b.Permissions, resolvedPresets, team.Name)
if err != nil {
return nil, err
}
if len(perms) == 0 {
return nil, fmt.Errorf("access_control: team %q binding %d has no permissions", team.Name, bi)
}
for perm := range perms {
if Vocabulary[perm].Scope != ScopePrefix {
return nil, fmt.Errorf("access_control: team %q binding %d: %q is a global permission, put it under cluster_permissions", team.Name, bi, perm)
}
}
tp.Bindings = append(tp.Bindings, Binding{BucketPrefixes: b.BucketPrefixes, Permissions: perms})
}
clusterPerms, err := resolvePermList(team.ClusterPermissions, resolvedPresets, team.Name)
if err != nil {
return nil, err
}
for perm := range clusterPerms {
if Vocabulary[perm].Scope != ScopeGlobal {
return nil, fmt.Errorf("access_control: team %q cluster_permissions: %q is prefix-scoped, put it in a binding", team.Name, perm)
}
}
tp.ClusterPerms = clusterPerms
p.Teams = append(p.Teams, tp)
idx := len(p.Teams) - 1
for _, cv := range team.ClaimValues {
p.byClaim[cv] = append(p.byClaim[cv], idx)
}
}
return p, nil
}
// resolvePermList turns a raw permission list (concrete names, preset refs,
// globs) into a validated PermSet.
func resolvePermList(raw []string, presets map[string]PermSet, teamName string) (PermSet, error) {
out := PermSet{}
for _, entry := range raw {
switch {
case strings.HasPrefix(entry, presetPrefix):
name := strings.TrimPrefix(entry, presetPrefix)
perms, ok := presets[name]
if !ok {
return nil, fmt.Errorf("access_control: team %q references unknown preset %q", teamName, name)
}
for perm := range perms {
out[perm] = struct{}{}
}
case strings.HasSuffix(entry, "*"):
expanded := ExpandGlob(entry)
if expanded == nil {
return nil, fmt.Errorf("access_control: team %q: glob %q matches no permission (unknown permission pattern)", teamName, entry)
}
for _, perm := range expanded {
out[perm] = struct{}{}
}
default:
spec, ok := Vocabulary[entry]
if !ok {
return nil, fmt.Errorf("access_control: team %q: unknown permission %q", teamName, entry)
}
if spec.AdminOnly {
return nil, fmt.Errorf("access_control: team %q: %q is admin-only in v1 and cannot be granted to a team", teamName, entry)
}
out[entry] = struct{}{}
}
}
return out, nil
}
// resolvePreset resolves one preset, following preset:… references with cycle
// detection. path carries the current resolution chain.
func resolvePreset(presets map[string][]string, name string, path []string) (PermSet, error) {
for _, seen := range path {
if seen == name {
return nil, fmt.Errorf("access_control: preset cycle detected: %s -> %s", strings.Join(path, " -> "), name)
}
}
entries, ok := presets[name]
if !ok {
return nil, fmt.Errorf("access_control: unknown preset %q", name)
}
out := PermSet{}
for _, entry := range entries {
switch {
case strings.HasPrefix(entry, presetPrefix):
sub, err := resolvePreset(presets, strings.TrimPrefix(entry, presetPrefix), append(path, name))
if err != nil {
return nil, err
}
for perm := range sub {
out[perm] = struct{}{}
}
case strings.HasSuffix(entry, "*"):
expanded := ExpandGlob(entry)
if expanded == nil {
return nil, fmt.Errorf("access_control: preset %q: glob %q matches no permission", name, entry)
}
for _, perm := range expanded {
out[perm] = struct{}{}
}
default:
spec, ok := Vocabulary[entry]
if !ok {
return nil, fmt.Errorf("access_control: preset %q: unknown permission %q", name, entry)
}
if spec.AdminOnly {
return nil, fmt.Errorf("access_control: preset %q: %q is admin-only in v1 and cannot be granted to a team", name, entry)
}
out[entry] = struct{}{}
}
}
return out, nil
}
// TeamsForClaims returns every team whose claim_values intersect claims.
// Multiple teams sharing a claim value are all returned (union semantics).
func (p *Policy) TeamsForClaims(claims []string) []*TeamPolicy {
if !p.Enabled {
return nil
}
seen := map[int]struct{}{}
var out []*TeamPolicy
for _, c := range claims {
for _, idx := range p.byClaim[c] {
if _, dup := seen[idx]; dup {
continue
}
seen[idx] = struct{}{}
out = append(out, &p.Teams[idx])
}
}
return out
}
-201
View File
@@ -1,201 +0,0 @@
package authz
import (
"strings"
"testing"
"Noooste/garage-ui/internal/config"
)
func validAC() *config.AccessControlConfig {
return &config.AccessControlConfig{
Presets: map[string][]string{
"bucket_readonly": {"bucket.list", "bucket.read", "object.list", "object.read"},
"bucket_owner": {"preset:bucket_readonly", "bucket.create", "bucket.update", "bucket.delete", "object.write", "object.delete"},
},
Teams: []config.TeamConfig{
{
Name: "backend",
ClaimValues: []string{"garage-team-backend"},
Bindings: []config.BindingConfig{
{BucketPrefixes: []string{"backend-"}, Permissions: []string{"preset:bucket_owner"}},
{BucketPrefixes: []string{"shared-"}, Permissions: []string{"preset:bucket_readonly"}},
},
ClusterPermissions: []string{"cluster.status", "cluster.health"},
},
},
}
}
func TestCompilePolicyNilConfig(t *testing.T) {
p, err := CompilePolicy(nil)
if err != nil {
t.Fatalf("CompilePolicy(nil): %v", err)
}
if p.Enabled {
t.Error("nil config must compile to disabled policy")
}
}
func TestCompilePolicyResolvesPresetsAndGlobs(t *testing.T) {
cfg := validAC()
cfg.Teams[0].Bindings[0].Permissions = append(cfg.Teams[0].Bindings[0].Permissions, "bucket_alias.*")
p, err := CompilePolicy(cfg)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
if !p.Enabled {
t.Fatal("policy should be enabled")
}
b0 := p.Teams[0].Bindings[0].Permissions
for _, want := range []string{"bucket.list", "bucket.read", "bucket.create", "object.delete", "bucket_alias.add", "bucket_alias.remove"} {
if _, ok := b0[want]; !ok {
t.Errorf("binding 0 missing %q after preset/glob resolution: %v", want, b0)
}
}
if _, ok := p.Teams[0].Bindings[1].Permissions["bucket.create"]; ok {
t.Error("readonly binding must not gain owner permissions")
}
if _, ok := p.Teams[0].ClusterPerms["cluster.status"]; !ok {
t.Error("cluster_permissions not compiled")
}
}
func TestCompilePolicyValidationErrors(t *testing.T) {
cases := []struct {
name string
mutate func(*config.AccessControlConfig)
errPart string
}{
{"unknown permission", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = []string{"bucket.explode"}
}, "unknown permission"},
{"unknown preset", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = []string{"preset:nope"}
}, "unknown preset"},
{"preset cycle", func(c *config.AccessControlConfig) {
c.Presets["a"] = []string{"preset:b"}
c.Presets["b"] = []string{"preset:a"}
c.Teams[0].Bindings[0].Permissions = []string{"preset:a"}
}, "cycle"},
{"admin-only to team", func(c *config.AccessControlConfig) {
c.Teams[0].ClusterPermissions = []string{"key.create"}
}, "admin-only"},
{"duplicate team name", func(c *config.AccessControlConfig) {
c.Teams = append(c.Teams, c.Teams[0])
}, "duplicate team"},
{"empty claim_values", func(c *config.AccessControlConfig) {
c.Teams[0].ClaimValues = nil
}, "claim_values"},
{"no bindings or cluster perms", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings = nil
c.Teams[0].ClusterPermissions = nil
}, "at least one"},
{"prefix-scoped perm in cluster_permissions", func(c *config.AccessControlConfig) {
c.Teams[0].ClusterPermissions = []string{"bucket.read"}
}, "prefix-scoped"},
{"global perm in binding", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = []string{"cluster.status"}
}, "global"},
{"binding without prefixes", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].BucketPrefixes = nil
}, "bucket_prefixes"},
{"binding with empty permissions", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = nil
}, "has no permissions"},
{"empty team name", func(c *config.AccessControlConfig) {
c.Teams[0].Name = ""
}, "has no name"},
{"glob matches nothing in binding", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = []string{"nonexistent.*"}
}, "matches no permission"},
{"preset references unknown preset", func(c *config.AccessControlConfig) {
c.Presets["broken"] = []string{"preset:ghost"}
}, "unknown preset"},
{"glob matches nothing in preset", func(c *config.AccessControlConfig) {
c.Presets["globby"] = []string{"nonexistent.*"}
}, "matches no permission"},
{"unknown permission in preset", func(c *config.AccessControlConfig) {
c.Presets["badperm"] = []string{"bucket.explode"}
}, "unknown permission"},
{"admin-only permission in preset", func(c *config.AccessControlConfig) {
c.Presets["adminy"] = []string{"key.create"}
}, "admin-only"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := validAC()
tc.mutate(cfg)
_, err := CompilePolicy(cfg)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.errPart)
}
if !strings.Contains(err.Error(), tc.errPart) {
t.Fatalf("error %q does not contain %q", err.Error(), tc.errPart)
}
})
}
}
func TestCompilePolicyPresetWithGlob(t *testing.T) {
// A preset may itself contain a trailing-star glob; it must expand at
// compile time just like a glob written directly in a binding.
cfg := validAC()
cfg.Presets["aliasops"] = []string{"bucket_alias.*"}
cfg.Teams[0].Bindings[0].Permissions = []string{"preset:aliasops"}
p, err := CompilePolicy(cfg)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
b0 := p.Teams[0].Bindings[0].Permissions
for _, want := range []string{"bucket_alias.add", "bucket_alias.remove"} {
if _, ok := b0[want]; !ok {
t.Errorf("preset glob did not expand %q: %v", want, b0)
}
}
}
func TestTeamsForClaimsDisabledReturnsNil(t *testing.T) {
p, err := CompilePolicy(nil)
if err != nil {
t.Fatal(err)
}
if got := p.TeamsForClaims([]string{"anything"}); got != nil {
t.Errorf("disabled policy matched %v, want nil", got)
}
}
func TestTeamsForClaimsDeduplicatesSameTeam(t *testing.T) {
// One team reachable via two claim values: presenting both must not
// return the team twice.
cfg := validAC()
cfg.Teams[0].ClaimValues = []string{"g-a", "g-b"}
p, err := CompilePolicy(cfg)
if err != nil {
t.Fatal(err)
}
if got := p.TeamsForClaims([]string{"g-a", "g-b"}); len(got) != 1 {
t.Fatalf("TeamsForClaims returned %d teams, want 1 (deduped)", len(got))
}
}
func TestTeamsForClaims(t *testing.T) {
cfg := validAC()
// Second team sharing a claim value with the first: union case from the issue.
cfg.Teams = append(cfg.Teams, config.TeamConfig{
Name: "backend-observers",
ClaimValues: []string{"garage-team-backend"},
ClusterPermissions: []string{"cluster.statistics"},
})
p, err := CompilePolicy(cfg)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
got := p.TeamsForClaims([]string{"garage-team-backend"})
if len(got) != 2 {
t.Fatalf("TeamsForClaims matched %d teams, want 2 (shared claim value)", len(got))
}
if got := p.TeamsForClaims([]string{"unrelated"}); len(got) != 0 {
t.Fatalf("unrelated claim matched %d teams, want 0", len(got))
}
}
-55
View File
@@ -1,55 +0,0 @@
package authz
import (
"Noooste/garage-ui/internal/auth"
)
// TeamResolver maps an authenticated identity to an authorization Subject.
// It is an interface so non-OIDC identity sources (deferred in v1) can plug
// in later without touching middleware or handlers.
type TeamResolver interface {
Resolve(userInfo *auth.UserInfo) Subject
}
type configTeamResolver struct {
policy *Policy
adminRoles []string
}
// NewTeamResolver builds the v1 resolver: OIDC identities resolve through the
// compiled policy; admin/token logins resolve to the synthetic admin subject
// (non-OIDC team mapping is deferred).
func NewTeamResolver(policy *Policy, adminRoles []string) TeamResolver {
return &configTeamResolver{policy: policy, adminRoles: adminRoles}
}
func (r *configTeamResolver) Resolve(userInfo *auth.UserInfo) Subject {
id := userInfo.Email
if id == "" {
id = userInfo.Username
}
// Trust only signed claims, never the transport channel: the auth method
// is a JWT claim stamped at login. Legacy sessions ("") resolve like OIDC
// so a replayed cookie can never escalate.
if userInfo.AuthMethod == "admin" || userInfo.AuthMethod == "token" {
return AdminSubject(id)
}
for _, role := range userInfo.Roles {
for _, adminRole := range r.adminRoles {
if role == adminRole {
return AdminSubject(id)
}
}
}
subj := Subject{ID: id, ClusterPerms: PermSet{}}
for _, team := range r.policy.TeamsForClaims(userInfo.Teams) {
subj.Bindings = append(subj.Bindings, team.Bindings...)
for perm := range team.ClusterPerms {
subj.ClusterPerms[perm] = struct{}{}
}
}
return subj
}
-93
View File
@@ -1,93 +0,0 @@
package authz
import (
"testing"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
)
func resolverFixture(t *testing.T) TeamResolver {
t.Helper()
policy, err := CompilePolicy(&config.AccessControlConfig{
Teams: []config.TeamConfig{
{
Name: "backend",
ClaimValues: []string{"g-backend"},
Bindings: []config.BindingConfig{
{BucketPrefixes: []string{"backend-"}, Permissions: []string{"bucket.list", "bucket.read"}},
},
ClusterPermissions: []string{"cluster.status"},
},
{
Name: "observers",
ClaimValues: []string{"g-backend", "g-obs"},
ClusterPermissions: []string{"cluster.statistics"},
},
},
})
if err != nil {
t.Fatal(err)
}
return NewTeamResolver(policy, []string{"garage-admin"})
}
func TestResolveOIDCTeamUser(t *testing.T) {
r := resolverFixture(t)
s := r.Resolve(&auth.UserInfo{Email: "a@x.com", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if s.IsAdmin {
t.Error("team user must not be admin")
}
if s.ID != "a@x.com" {
t.Errorf("ID = %q", s.ID)
}
if len(s.Bindings) != 1 {
t.Fatalf("bindings = %d, want 1", len(s.Bindings))
}
// Union across the two teams matched by g-backend.
if _, ok := s.ClusterPerms["cluster.status"]; !ok {
t.Error("missing cluster.status")
}
if _, ok := s.ClusterPerms["cluster.statistics"]; !ok {
t.Error("missing cluster.statistics from second team sharing the claim")
}
}
func TestResolveOIDCAdminRole(t *testing.T) {
r := resolverFixture(t)
s := r.Resolve(&auth.UserInfo{Username: "root", AuthMethod: "oidc", Roles: []string{"garage-admin"}})
if !s.IsAdmin {
t.Error("admin-role user must resolve to admin subject")
}
}
func TestResolveNonOIDCIsAdmin(t *testing.T) {
r := resolverFixture(t)
for _, method := range []string{"admin", "token"} {
s := r.Resolve(&auth.UserInfo{Username: "op", AuthMethod: method})
if !s.IsAdmin {
t.Errorf("method %q must resolve to admin (deferred: non-OIDC team mapping)", method)
}
}
}
func TestResolveLegacySessionFailsClosed(t *testing.T) {
// Pre-upgrade JWTs have no auth_method claim. Resolve them like OIDC:
// roles can still grant admin, but there is no channel-based trust.
r := resolverFixture(t)
s := r.Resolve(&auth.UserInfo{Username: "old", AuthMethod: ""})
if s.IsAdmin {
t.Error("legacy session without admin role must not be admin")
}
if len(s.Bindings) != 0 || len(s.ClusterPerms) != 0 {
t.Error("legacy session with no teams must have zero permissions")
}
}
func TestResolveZeroTeamUser(t *testing.T) {
r := resolverFixture(t)
s := r.Resolve(&auth.UserInfo{Email: "b@x.com", AuthMethod: "oidc", Teams: []string{"unmatched"}})
if s.IsAdmin || len(s.Bindings) != 0 || len(s.ClusterPerms) != 0 {
t.Errorf("zero-team subject must have nothing: %+v", s)
}
}
-136
View File
@@ -1,136 +0,0 @@
// Package authz implements the UI-layer access-control policy for garage-ui.
//
// IMPORTANT: this is UI-layer policy, not a security boundary. garage-ui talks
// to Garage with a single admin token and a single S3 credential set; anyone
// holding those bypasses everything here.
package authz
import "strings"
// ScopeKind says what a permission applies to.
type ScopeKind int
const (
// ScopeGlobal permissions are all-or-nothing per team (cluster_permissions).
ScopeGlobal ScopeKind = iota
// ScopePrefix permissions are gated by a binding's bucket_prefixes.
ScopePrefix
)
// PermSpec describes one abstract permission. Endpoints lists the Garage admin
// API endpoint names (or S3 data-plane operations) the permission maps to.
// This registry is the only place those names appear in authz.
type PermSpec struct {
Scope ScopeKind
AdminOnly bool // not grantable to teams in v1; only the synthetic admin subject holds it
Endpoints []string
}
// Permission constants for every permission referenced from route wiring.
const (
PermBucketList = "bucket.list"
PermBucketRead = "bucket.read"
PermBucketCreate = "bucket.create"
PermBucketUpdate = "bucket.update"
PermBucketDelete = "bucket.delete"
PermObjectList = "object.list"
PermObjectRead = "object.read"
PermObjectWrite = "object.write"
PermObjectDelete = "object.delete"
PermAllowBucketKey = "permission.allow_bucket_key"
PermDenyBucketKey = "permission.deny_bucket_key"
PermKeyList = "key.list"
PermKeyRead = "key.read"
PermKeyReadSecret = "key.read_secret"
PermKeyCreate = "key.create"
PermKeyUpdate = "key.update"
PermKeyDelete = "key.delete"
PermClusterStatus = "cluster.status"
PermClusterHealth = "cluster.health"
PermClusterStatistics = "cluster.statistics"
PermNodeInfo = "node.info"
PermNodeStatistics = "node.statistics"
)
// Vocabulary is the full v1 permission registry, ratified in issue #33.
// admin_token.* is deliberately absent (admin-only implicitly, not modeled).
var Vocabulary = map[string]PermSpec{
"bucket.list": {Scope: ScopePrefix, Endpoints: []string{"ListBuckets"}},
"bucket.read": {Scope: ScopePrefix, Endpoints: []string{"GetBucketInfo"}},
"bucket.create": {Scope: ScopePrefix, Endpoints: []string{"CreateBucket"}},
"bucket.update": {Scope: ScopePrefix, Endpoints: []string{"UpdateBucket"}},
"bucket.delete": {Scope: ScopePrefix, Endpoints: []string{"DeleteBucket"}},
"bucket.cleanup_uploads": {Scope: ScopePrefix, Endpoints: []string{"CleanupIncompleteUploads"}},
"bucket.inspect_object": {Scope: ScopePrefix, Endpoints: []string{"InspectObject"}},
"bucket_alias.add": {Scope: ScopePrefix, Endpoints: []string{"AddBucketAlias"}},
"bucket_alias.remove": {Scope: ScopePrefix, Endpoints: []string{"RemoveBucketAlias"}},
// S3 data plane (object browser), no Garage admin endpoint.
"object.list": {Scope: ScopePrefix, Endpoints: []string{"S3:ListObjectsV2"}},
"object.read": {Scope: ScopePrefix, Endpoints: []string{"S3:GetObject", "S3:HeadObject", "S3:PresignGet"}},
"object.write": {Scope: ScopePrefix, Endpoints: []string{"S3:PutObject"}},
"object.delete": {Scope: ScopePrefix, Endpoints: []string{"S3:DeleteObject", "S3:DeleteObjects"}},
"permission.allow_bucket_key": {Scope: ScopePrefix, Endpoints: []string{"AllowBucketKey"}},
"permission.deny_bucket_key": {Scope: ScopePrefix, Endpoints: []string{"DenyBucketKey"}},
"key.list": {Scope: ScopeGlobal, Endpoints: []string{"ListKeys"}},
"key.read": {Scope: ScopeGlobal, Endpoints: []string{"GetKeyInfo"}},
"key.read_secret": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"GetKeyInfo(showSecretKey)"}},
"key.create": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"CreateKey"}},
"key.import": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"ImportKey"}},
"key.update": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"UpdateKey"}},
"key.delete": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"DeleteKey"}},
"cluster.status": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterStatus"}},
"cluster.health": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterHealth"}},
"cluster.statistics": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterStatistics"}},
"cluster.connect_nodes": {Scope: ScopeGlobal, Endpoints: []string{"ConnectClusterNodes"}},
"cluster.layout.read": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterLayout"}},
"cluster.layout.history": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterLayoutHistory"}},
"cluster.layout.apply": {Scope: ScopeGlobal, Endpoints: []string{"ApplyClusterLayout"}},
"cluster.layout.skip_dead_nodes": {Scope: ScopeGlobal, Endpoints: []string{"ClusterLayoutSkipDeadNodes"}},
"node.info": {Scope: ScopeGlobal, Endpoints: []string{"GetNodeInfo"}},
"node.statistics": {Scope: ScopeGlobal, Endpoints: []string{"GetNodeStatistics"}},
"node.snapshot": {Scope: ScopeGlobal, Endpoints: []string{"CreateMetadataSnapshot"}},
"node.repair": {Scope: ScopeGlobal, Endpoints: []string{"LaunchRepairOperation"}},
"worker.list": {Scope: ScopeGlobal, Endpoints: []string{"ListWorkers"}},
"worker.info": {Scope: ScopeGlobal, Endpoints: []string{"GetWorkerInfo"}},
"worker.get_variable": {Scope: ScopeGlobal, Endpoints: []string{"GetWorkerVariable"}},
"worker.set_variable": {Scope: ScopeGlobal, Endpoints: []string{"SetWorkerVariable"}},
"block.list_errors": {Scope: ScopeGlobal, Endpoints: []string{"ListBlockErrors"}},
"block.info": {Scope: ScopeGlobal, Endpoints: []string{"GetBlockInfo"}},
}
// IsValidPermission reports whether p names a concrete vocabulary entry.
// Globs are patterns, not permissions, and return false.
func IsValidPermission(p string) bool {
_, ok := Vocabulary[p]
return ok
}
// ExpandGlob expands a trailing-star pattern ("bucket.*", "cluster.layout.*",
// bare "*") against the vocabulary. Admin-only permissions are never matched
// by globs; they must be held via the synthetic admin subject. Returns nil
// when the pattern matches nothing or has no trailing star.
func ExpandGlob(pattern string) []string {
if !strings.HasSuffix(pattern, "*") {
return nil
}
prefix := strings.TrimSuffix(pattern, "*")
var out []string
for name, spec := range Vocabulary {
if spec.AdminOnly {
continue
}
if strings.HasPrefix(name, prefix) {
out = append(out, name)
}
}
return out
}
-122
View File
@@ -1,122 +0,0 @@
package authz
import (
"sort"
"testing"
)
func TestExpandGlobRejectsNonGlob(t *testing.T) {
// A pattern without a trailing star is not a glob and expands to nothing.
if got := ExpandGlob("bucket.read"); got != nil {
t.Errorf("ExpandGlob(%q) = %v, want nil", "bucket.read", got)
}
}
func TestVocabularyContainsRatifiedPermissions(t *testing.T) {
// Spot-check one permission per family plus scope/admin flags.
cases := []struct {
perm string
scope ScopeKind
adminOnly bool
}{
{"bucket.list", ScopePrefix, false},
{"bucket.read", ScopePrefix, false},
{"bucket.create", ScopePrefix, false},
{"bucket_alias.add", ScopePrefix, false},
{"object.list", ScopePrefix, false},
{"object.read", ScopePrefix, false},
{"object.write", ScopePrefix, false},
{"object.delete", ScopePrefix, false},
{"permission.allow_bucket_key", ScopePrefix, false},
{"key.list", ScopeGlobal, false},
{"key.read", ScopeGlobal, false},
{"key.read_secret", ScopeGlobal, true},
{"key.create", ScopeGlobal, true},
{"key.import", ScopeGlobal, true},
{"key.update", ScopeGlobal, true},
{"key.delete", ScopeGlobal, true},
{"cluster.status", ScopeGlobal, false},
{"cluster.layout.apply", ScopeGlobal, false},
{"node.repair", ScopeGlobal, false},
{"worker.set_variable", ScopeGlobal, false},
{"block.info", ScopeGlobal, false},
}
for _, tc := range cases {
spec, ok := Vocabulary[tc.perm]
if !ok {
t.Errorf("missing permission %q", tc.perm)
continue
}
if spec.Scope != tc.scope {
t.Errorf("%s: scope = %v, want %v", tc.perm, spec.Scope, tc.scope)
}
if spec.AdminOnly != tc.adminOnly {
t.Errorf("%s: adminOnly = %v, want %v", tc.perm, spec.AdminOnly, tc.adminOnly)
}
}
if _, ok := Vocabulary["admin_token.list"]; ok {
t.Error("admin_token.* must not be in the v1 vocabulary")
}
if len(Vocabulary) != 40 {
t.Errorf("vocabulary size = %d, want 40", len(Vocabulary))
}
}
func TestIsValidPermission(t *testing.T) {
if !IsValidPermission("bucket.read") {
t.Error("bucket.read should be valid")
}
if IsValidPermission("bucket.explode") {
t.Error("bucket.explode should be invalid")
}
if IsValidPermission("bucket.*") {
t.Error("globs are not permissions; IsValidPermission must reject them")
}
}
func TestExpandGlob(t *testing.T) {
got := ExpandGlob("object.*")
sort.Strings(got)
want := []string{"object.delete", "object.list", "object.read", "object.write"}
if len(got) != len(want) {
t.Fatalf("object.* expanded to %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("object.* expanded to %v, want %v", got, want)
}
}
// key.* must NOT include admin-only permissions.
for _, p := range ExpandGlob("key.*") {
if Vocabulary[p].AdminOnly {
t.Errorf("glob expansion included admin-only permission %q", p)
}
}
// cluster.* includes cluster.layout.* (prefix match on the dotted name).
found := false
for _, p := range ExpandGlob("cluster.*") {
if p == "cluster.layout.apply" {
found = true
}
}
if !found {
t.Error("cluster.* should include cluster.layout.apply")
}
// Bare * expands to every non-admin-only permission.
star := ExpandGlob("*")
if len(star) == 0 {
t.Fatal("* expanded to nothing")
}
for _, p := range star {
if Vocabulary[p].AdminOnly {
t.Errorf("* expansion included admin-only permission %q", p)
}
}
if got := ExpandGlob("nonexistent.*"); got != nil {
t.Errorf("nonexistent.* should expand to nil, got %v", got)
}
}
+16 -197
View File
@@ -2,24 +2,19 @@ package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/spf13/viper"
"Noooste/garage-ui/pkg/logger"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Garage GarageConfig `mapstructure:"garage"`
Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Logging LoggingConfig `mapstructure:"logging"`
AccessControl *AccessControlConfig `mapstructure:"access_control"`
Server ServerConfig `mapstructure:"server"`
Garage GarageConfig `mapstructure:"garage"`
Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Logging LoggingConfig `mapstructure:"logging"`
}
// ServerConfig contains server-related configuration
@@ -49,11 +44,9 @@ type GarageConfig struct {
// AuthConfig contains authentication configuration
type AuthConfig struct {
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
Token TokenAuthConfig `mapstructure:"token"`
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
}
// AdminAuthConfig contains admin authentication settings
@@ -63,12 +56,6 @@ type AdminAuthConfig struct {
Password string `mapstructure:"password"`
}
// TokenAuthConfig contains admin token authentication settings.
// When enabled, users can log in using the Garage admin token.
type TokenAuthConfig struct {
Enabled bool `mapstructure:"enabled"`
}
// OIDCConfig contains OIDC authentication settings
type OIDCConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -83,9 +70,7 @@ type OIDCConfig struct {
UsernameAttribute string `mapstructure:"username_attribute"`
NameAttribute string `mapstructure:"name_attribute"`
RoleAttributePath string `mapstructure:"role_attribute_path"`
TeamAttributePath string `mapstructure:"team_attribute_path"`
AdminRole string `mapstructure:"admin_role"`
AdminRoles []string `mapstructure:"admin_roles"`
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
SessionMaxAge int `mapstructure:"session_max_age"`
CookieName string `mapstructure:"cookie_name"`
@@ -94,29 +79,6 @@ type OIDCConfig struct {
CookieSameSite string `mapstructure:"cookie_same_site"`
}
// EffectiveAdminRoles returns the deduplicated list of admin roles drawn from
// both admin_role (legacy single-value) and admin_roles (list). A user is
// considered an admin if any of their roles matches any entry in this list.
func (o OIDCConfig) EffectiveAdminRoles() []string {
seen := make(map[string]struct{}, len(o.AdminRoles)+1)
var roles []string
add := func(r string) {
if r == "" {
return
}
if _, ok := seen[r]; ok {
return
}
seen[r] = struct{}{}
roles = append(roles, r)
}
add(o.AdminRole)
for _, r := range o.AdminRoles {
add(r)
}
return roles
}
// CORSConfig contains CORS settings for frontend communication
type CORSConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -133,55 +95,8 @@ type LoggingConfig struct {
Format string `mapstructure:"format"`
}
// AccessControlConfig is the optional access_control section. nil (section
// absent) preserves historical behavior: every authenticated user is admin.
// When present, authorization is default-deny and detailed policy validation
// happens in internal/authz.CompilePolicy at startup.
// This section is config-file only (no env-var binding: nested lists don't
// map to flat env vars).
type AccessControlConfig struct {
Presets map[string][]string `mapstructure:"presets"`
Teams []TeamConfig `mapstructure:"teams"`
}
// TeamConfig binds a set of IdP claim values to bucket-prefix bindings and
// cluster-level permissions.
type TeamConfig struct {
Name string `mapstructure:"name"`
ClaimValues []string `mapstructure:"claim_values"`
Bindings []BindingConfig `mapstructure:"bindings"`
ClusterPermissions []string `mapstructure:"cluster_permissions"`
}
// BindingConfig grants a set of permissions (or presets) over buckets whose
// names match one of the given prefixes.
type BindingConfig struct {
BucketPrefixes []string `mapstructure:"bucket_prefixes"`
Permissions []string `mapstructure:"permissions"`
}
// LoadOption configures optional behaviour of Load.
type LoadOption func(*loadOptions)
type loadOptions struct {
garageTomlPath string
}
// WithGarageToml tells Load to parse a garage.toml file and use its values as
// lowest-priority defaults (below YAML, below env vars).
func WithGarageToml(path string) LoadOption {
return func(o *loadOptions) {
o.garageTomlPath = path
}
}
// Load reads the configuration from the specified file
func Load(configPath string, opts ...LoadOption) (*Config, error) {
var lo loadOptions
for _, fn := range opts {
fn(&lo)
}
func Load(configPath string) (*Config, error) {
// Set default config file name if not specified
if configPath == "" {
configPath = "config.yaml"
@@ -191,32 +106,6 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
viper.SetConfigFile(configPath)
viper.SetConfigType("yaml")
// Built-in defaults (lowest priority)
viper.SetDefault("server.host", "::")
viper.SetDefault("server.port", 8080)
viper.SetDefault("server.environment", "production")
viper.SetDefault("garage.force_path_style", true)
viper.SetDefault("logging.level", "info")
viper.SetDefault("logging.format", "text")
viper.SetDefault("auth.oidc.cookie_name", "garage_session")
viper.SetDefault("auth.oidc.cookie_http_only", true)
viper.SetDefault("auth.oidc.cookie_same_site", "lax")
viper.SetDefault("auth.oidc.session_max_age", 86400)
// If garage.toml path is provided, parse it and set values as viper
// defaults. Defaults sit below config-file and env-var values in viper's
// priority order, so YAML and env vars will still win.
if lo.garageTomlPath != "" {
tomlResult, err := ParseGarageToml(lo.garageTomlPath)
if err != nil {
return nil, fmt.Errorf("error parsing garage.toml: %w", err)
}
viper.SetDefault("garage.endpoint", tomlResult.Endpoint)
viper.SetDefault("garage.admin_endpoint", tomlResult.AdminEndpoint)
viper.SetDefault("garage.admin_token", tomlResult.AdminToken)
viper.SetDefault("garage.region", tomlResult.Region)
}
// Allow environment variables to override config values
// Environment variables take precedence over config file
viper.AutomaticEnv()
@@ -226,13 +115,6 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
// Env vars override config file values
bindEnvVars()
// Resolve `_FILE`-suffixed env vars for sensitive values (e.g.
// {ENV}_FILE=/run/secrets/foo). Must run after bindEnvVars so the
// warning about both forms being set fires correctly.
if err := applyFileBackedEnvVars(); err != nil {
return nil, fmt.Errorf("error resolving _FILE env vars: %w", err)
}
// Read the config file (optional - will use defaults and env vars if not found)
if _, err := os.Stat(configPath); err == nil {
if err := viper.ReadInConfig(); err != nil {
@@ -246,16 +128,6 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
// mapstructure leaves AccessControl nil when the section is present but
// decodes to an empty map (e.g. "access_control: {}"), even though
// viper.IsSet still reports it present. AccessControlConfig's documented
// semantics are presence-based, not content-based ("nil = absent =
// historical behavior"; "present, even empty = enabled default-deny"),
// so force allocation here rather than silently falling back to nil.
if cfg.AccessControl == nil && viper.IsSet("access_control") {
cfg.AccessControl = &AccessControlConfig{}
}
// Validate the configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
@@ -292,10 +164,6 @@ func bindEnvVars() {
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
viper.BindEnv("auth.metrics_public", "GARAGE_UI_AUTH_METRICS_PUBLIC")
// Token auth config
viper.BindEnv("auth.token.enabled", "GARAGE_UI_AUTH_TOKEN_ENABLED")
// OIDC config
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
@@ -310,9 +178,7 @@ func bindEnvVars() {
viper.BindEnv("auth.oidc.username_attribute", "GARAGE_UI_AUTH_OIDC_USERNAME_ATTRIBUTE")
viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_ATTRIBUTE")
viper.BindEnv("auth.oidc.role_attribute_path", "GARAGE_UI_AUTH_OIDC_ROLE_ATTRIBUTE_PATH")
viper.BindEnv("auth.oidc.team_attribute_path", "GARAGE_UI_AUTH_OIDC_TEAM_ATTRIBUTE_PATH")
viper.BindEnv("auth.oidc.admin_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE")
viper.BindEnv("auth.oidc.admin_roles", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLES")
viper.BindEnv("auth.oidc.tls_skip_verify", "GARAGE_UI_AUTH_OIDC_TLS_SKIP_VERIFY")
viper.BindEnv("auth.oidc.session_max_age", "GARAGE_UI_AUTH_OIDC_SESSION_MAX_AGE")
viper.BindEnv("auth.oidc.cookie_name", "GARAGE_UI_AUTH_OIDC_COOKIE_NAME")
@@ -333,50 +199,6 @@ func bindEnvVars() {
viper.BindEnv("logging.format", "GARAGE_UI_LOGGING_FORMAT")
}
// fileBackedEnvVars maps env var names to viper config keys for variables that
// support the `_FILE` suffix convention. Operators may set `{ENV}_FILE` to a
// file path; the file's contents (with trailing whitespace trimmed) become the
// effective value. This pattern is used by Docker Official Images (postgres,
// mysql) to inject secrets via mounted files instead of plain env vars,
// avoiding exposure through `docker inspect`, process listings, or crash logs.
//
// Scope is intentionally limited to values that an operator would reasonably
// store in a Kubernetes Secret or Docker secret. Non-sensitive config (host,
// port, endpoints, etc.) is excluded.
var fileBackedEnvVars = map[string]string{
"GARAGE_UI_GARAGE_ADMIN_TOKEN": "garage.admin_token",
"GARAGE_UI_AUTH_ADMIN_USERNAME": "auth.admin.username",
"GARAGE_UI_AUTH_ADMIN_PASSWORD": "auth.admin.password",
"GARAGE_UI_AUTH_JWT_PRIVATE_KEY": "auth.jwt_private_key",
"GARAGE_UI_AUTH_OIDC_CLIENT_ID": "auth.oidc.client_id",
"GARAGE_UI_AUTH_OIDC_CLIENT_SECRET": "auth.oidc.client_secret",
}
// applyFileBackedEnvVars resolves `_FILE`-suffixed env vars listed in
// fileBackedEnvVars. For each entry where `{ENV}_FILE` is set, the file is
// read and its contents (trimmed of trailing CR/LF) become the value via
// viper.Set, which is the highest-priority source — so a `_FILE` value wins
// over both `{ENV}` and YAML. A missing or unreadable file is a hard error.
func applyFileBackedEnvVars() error {
for envVar, configKey := range fileBackedEnvVars {
path := os.Getenv(envVar + "_FILE")
if path == "" {
continue
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s_FILE (%s): %w", envVar, path, err)
}
if os.Getenv(envVar) != "" {
logger.Warn().
Str("env", envVar).
Msg("both VAR and VAR_FILE are set; VAR_FILE takes precedence")
}
viper.Set(configKey, strings.TrimRight(string(data), "\r\n"))
}
return nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Validate server config
@@ -416,15 +238,12 @@ func (c *Config) Validate() error {
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when oidc is enabled")
}
// With access_control configured, default-deny protects unmatched
// users, so admin roles become optional. Without it, every
// authenticated route grants full admin access, so an empty admin
// role list would promote every IdP user to cluster admin.
if c.AccessControl == nil && len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled without access_control: leaving them empty would grant cluster-admin access to any authenticated IdP user")
}
if c.AccessControl != nil && len(c.AccessControl.Teams) > 0 && c.Auth.OIDC.TeamAttributePath == "" {
return fmt.Errorf("auth.oidc.team_attribute_path is required when access_control.teams is set: teams cannot be resolved without it")
// Every authenticated route on this service grants full admin
// access — there is no separate authorization layer. An empty
// admin_role would therefore promote every user in the IdP realm
// to cluster admin. Require operators to opt in explicitly.
if c.Auth.OIDC.AdminRole == "" {
return fmt.Errorf("oidc admin_role is required when oidc is enabled: leaving it empty would grant cluster-admin access to any authenticated IdP user")
}
}
@@ -433,7 +252,7 @@ func (c *Config) Validate() error {
// GetAddress returns the full server address (host:port)
func (c *Config) GetAddress() string {
return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
// IsDevelopment returns true if running in development mode
+1 -547
View File
@@ -3,7 +3,6 @@ package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -83,9 +82,6 @@ func TestLoad_EnvOnly_MissingFile(t *testing.T) {
if cfg.Server.Port != 9090 {
t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port)
}
if cfg.Server.Host != "::" {
t.Errorf("Server.Host = %q, want :: (default)", cfg.Server.Host)
}
if cfg.Garage.AdminToken != "env-token" {
t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken)
}
@@ -296,50 +292,13 @@ func TestValidate(t *testing.T) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
},
wantErrContains: "oidc admin_role or admin_roles is required",
},
{
name: "oidc enabled with admin_roles only is valid",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
c.Auth.OIDC.AdminRoles = []string{"group1", "group2"}
},
wantErrContains: "",
},
{
name: "oidc enabled with both admin_role and admin_roles is valid",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRoles = []string{"group2", "group3"}
},
wantErrContains: "",
},
{
name: "oidc enabled with empty admin_role and empty admin_roles rejected",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
c.Auth.OIDC.AdminRoles = []string{}
},
wantErrContains: "oidc admin_role or admin_roles is required",
wantErrContains: "oidc admin_role is required",
},
{
name: "oidc fully configured is valid",
mutate: applyValidOIDC,
wantErrContains: "",
},
{
name: "access_control teams without team_attribute_path rejected",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.TeamAttributePath = ""
c.AccessControl = &AccessControlConfig{
Teams: []TeamConfig{{Name: "t", ClaimValues: []string{"g"}}},
}
},
wantErrContains: "team_attribute_path is required",
},
{
name: "oidc disabled ignores missing client_id",
mutate: func(c *Config) {
@@ -381,8 +340,6 @@ func TestGetAddress(t *testing.T) {
}{
{"localhost", 8080, "localhost:8080"},
{"0.0.0.0", 80, "0.0.0.0:80"},
{"::", 80, "[::]:80"},
{"::1", 443, "[::1]:443"},
{"", 443, ":443"},
}
for _, tc := range tests {
@@ -417,463 +374,6 @@ func TestIsDevelopment(t *testing.T) {
}
}
func writeToml(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "garage.toml")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write toml: %v", err)
}
return path
}
const testGarageToml = `
[admin]
api_bind_addr = "[::]:3903"
admin_token = "toml-token"
[s3_api]
api_bind_addr = "[::]:3900"
s3_region = "garage"
`
func TestLoad_GarageTomlOnly(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
missingYaml := filepath.Join(t.TempDir(), "nope.yaml")
cfg, err := Load(missingYaml, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "toml-token" {
t.Errorf("AdminToken = %q, want toml-token", cfg.Garage.AdminToken)
}
if cfg.Garage.Endpoint != "http://127.0.0.1:3900" {
t.Errorf("Endpoint = %q, want http://127.0.0.1:3900", cfg.Garage.Endpoint)
}
if cfg.Garage.AdminEndpoint != "http://127.0.0.1:3903" {
t.Errorf("AdminEndpoint = %q, want http://127.0.0.1:3903", cfg.Garage.AdminEndpoint)
}
if cfg.Garage.Region != "garage" {
t.Errorf("Region = %q, want garage", cfg.Garage.Region)
}
}
func TestLoad_YAMLOverridesToml(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
yaml := `
server:
host: "0.0.0.0"
port: 8080
garage:
endpoint: http://custom:3900
admin_endpoint: http://custom:3903
admin_token: yaml-wins
`
yamlPath := writeConfigFile(t, yaml)
cfg, err := Load(yamlPath, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "yaml-wins" {
t.Errorf("AdminToken = %q, want yaml-wins (yaml overrides toml)", cfg.Garage.AdminToken)
}
if cfg.Garage.Endpoint != "http://custom:3900" {
t.Errorf("Endpoint = %q, want http://custom:3900", cfg.Garage.Endpoint)
}
}
func TestLoad_EnvOverridesToml(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
missingYaml := filepath.Join(t.TempDir(), "nope.yaml")
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins")
cfg, err := Load(missingYaml, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "env-wins" {
t.Errorf("AdminToken = %q, want env-wins (env overrides toml)", cfg.Garage.AdminToken)
}
}
// oidcValidYAML is a minimal configuration that enables OIDC and passes
// Validate, but deliberately omits auth.oidc.cookie_name.
const oidcValidYAML = `
server:
host: "0.0.0.0"
port: 8080
root_url: "https://garage.example.com"
garage:
endpoint: http://garage:3900
admin_endpoint: http://garage:3903
admin_token: supersecret
auth:
oidc:
enabled: true
client_id: "garage-ui"
issuer_url: "https://idp.example.com/realms/main"
scopes:
- openid
admin_roles:
- "garage-ui-admin"
`
func TestLoad_OIDCCookieNameDefaultsWhenUnset(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, oidcValidYAML)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
// An empty cookie name makes Fiber silently drop the session Set-Cookie
// (net/http rejects empty cookie names), which manifests as an OIDC login
// loop. A non-empty default prevents that footgun.
if cfg.Auth.OIDC.CookieName != "garage_session" {
t.Errorf("CookieName = %q, want garage_session (default)", cfg.Auth.OIDC.CookieName)
}
}
func TestLoad_OIDCCookieNameExplicitValueWins(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, oidcValidYAML+" cookie_name: \"custom_session\"\n")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Auth.OIDC.CookieName != "custom_session" {
t.Errorf("CookieName = %q, want custom_session (explicit override)", cfg.Auth.OIDC.CookieName)
}
}
func TestLoad_OIDCCookieDefaultsWhenUnset(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, oidcValidYAML)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
// HTTPOnly must default to true: a session cookie readable from JavaScript
// is an XSS token-theft risk.
if !cfg.Auth.OIDC.CookieHTTPOnly {
t.Errorf("CookieHTTPOnly = false, want true (default)")
}
// SessionMaxAge must default to a positive value so the cookie's MaxAge
// agrees with the 24h JWT instead of becoming a session-only cookie.
if cfg.Auth.OIDC.SessionMaxAge != 86400 {
t.Errorf("SessionMaxAge = %d, want 86400 (default)", cfg.Auth.OIDC.SessionMaxAge)
}
if cfg.Auth.OIDC.CookieSameSite != "lax" {
t.Errorf("CookieSameSite = %q, want lax (default)", cfg.Auth.OIDC.CookieSameSite)
}
}
func TestLoad_OIDCCookieDefaultsCanBeOverridden(t *testing.T) {
resetViper(t)
yaml := oidcValidYAML +
" cookie_http_only: false\n" +
" session_max_age: 3600\n" +
" cookie_same_site: \"strict\"\n"
path := writeConfigFile(t, yaml)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Auth.OIDC.CookieHTTPOnly {
t.Errorf("CookieHTTPOnly = true, want false (explicit override)")
}
if cfg.Auth.OIDC.SessionMaxAge != 3600 {
t.Errorf("SessionMaxAge = %d, want 3600 (explicit override)", cfg.Auth.OIDC.SessionMaxAge)
}
if cfg.Auth.OIDC.CookieSameSite != "strict" {
t.Errorf("CookieSameSite = %q, want strict (explicit override)", cfg.Auth.OIDC.CookieSameSite)
}
}
func TestEffectiveAdminRoles(t *testing.T) {
tests := []struct {
name string
adminRole string
adminRoles []string
want []string
}{
{"both empty", "", nil, nil},
{"single only", "admin", nil, []string{"admin"}},
{"list only", "", []string{"a", "b"}, []string{"a", "b"}},
{"merge single + list", "admin", []string{"viewer", "ops"}, []string{"admin", "viewer", "ops"}},
{"dedupes overlap", "admin", []string{"admin", "ops"}, []string{"admin", "ops"}},
{"dedupes within list", "", []string{"a", "a", "b"}, []string{"a", "b"}},
{"skips empty strings in list", "admin", []string{"", "ops", ""}, []string{"admin", "ops"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
o := OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles}
got := o.EffectiveAdminRoles()
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("EffectiveAdminRoles() = %v, want %v", got, tc.want)
}
})
}
}
// writeSecretFile is a test helper that writes content to a temp file and
// returns the absolute path. Uses t.TempDir so cleanup is automatic.
func writeSecretFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "secret")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp secret: %v", err)
}
return path
}
func TestApplyFileBackedEnvVars(t *testing.T) {
tests := []struct {
name string
envVar string
configKey string
fileBody string
alsoSetEnv string
useMissingFile bool
wantValue string
wantErr bool
}{
{
name: "reads value from file",
envVar: "GARAGE_UI_AUTH_ADMIN_PASSWORD",
configKey: "auth.admin.password",
fileBody: "s3cret",
wantValue: "s3cret",
},
{
name: "trims trailing newline",
envVar: "GARAGE_UI_GARAGE_ADMIN_TOKEN",
configKey: "garage.admin_token",
fileBody: "tok\n",
wantValue: "tok",
},
{
name: "trims trailing CRLF",
envVar: "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET",
configKey: "auth.oidc.client_secret",
fileBody: "secret\r\n",
wantValue: "secret",
},
{
name: "_FILE wins over plain env var",
envVar: "GARAGE_UI_AUTH_ADMIN_USERNAME",
configKey: "auth.admin.username",
fileBody: "from-file",
alsoSetEnv: "from-env",
wantValue: "from-file",
},
{
name: "missing file returns error",
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
configKey: "auth.jwt_private_key",
useMissingFile: true,
wantErr: true,
},
{
name: "multiline PEM preserved internally, only trailing whitespace trimmed",
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
configKey: "auth.jwt_private_key",
fileBody: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n",
wantValue: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resetViper(t)
if tc.useMissingFile {
t.Setenv(tc.envVar+"_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
} else {
path := writeSecretFile(t, tc.fileBody)
t.Setenv(tc.envVar+"_FILE", path)
}
if tc.alsoSetEnv != "" {
t.Setenv(tc.envVar, tc.alsoSetEnv)
}
err := applyFileBackedEnvVars()
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := viper.GetString(tc.configKey); got != tc.wantValue {
t.Fatalf("viper.GetString(%q) = %q, want %q", tc.configKey, got, tc.wantValue)
}
})
}
}
func TestApplyFileBackedEnvVars_NoFileEnvSet_NoOp(t *testing.T) {
resetViper(t)
if err := applyFileBackedEnvVars(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := viper.GetString("auth.admin.password"); got != "" {
t.Fatalf("expected empty password, got %q", got)
}
}
func TestLoad_FileBackedEnvVarMissingFileReturnsError(t *testing.T) {
resetViper(t)
yamlPath := writeConfigFile(t, minimalValidYAML)
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
_, err := Load(yamlPath)
if err == nil {
t.Fatal("expected error from Load when _FILE points at a missing file, got nil")
}
if !strings.Contains(err.Error(), "error resolving _FILE env vars") {
t.Errorf("error %q does not contain wrapped prefix from Load", err)
}
}
func TestAccessControlConfigParsing(t *testing.T) {
resetViper(t)
dir := t.TempDir()
cfgFile := filepath.Join(dir, "config.yaml")
yaml := `
server:
port: 8080
garage:
endpoint: "http://localhost:3900"
admin_endpoint: "http://localhost:3903"
admin_token: "test-token"
auth:
oidc:
enabled: false
team_attribute_path: "groups"
access_control:
presets:
bucket_readonly: [bucket.list, bucket.read]
teams:
- name: backend
claim_values: ["garage-team-backend"]
bindings:
- bucket_prefixes: ["backend-"]
permissions: ["preset:bucket_readonly", "bucket.create"]
cluster_permissions: [cluster.status]
`
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(cfgFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AccessControl == nil {
t.Fatal("AccessControl is nil, want parsed section")
}
if got := cfg.Auth.OIDC.TeamAttributePath; got != "groups" {
t.Errorf("TeamAttributePath = %q, want groups", got)
}
if len(cfg.AccessControl.Teams) != 1 {
t.Fatalf("teams = %d, want 1", len(cfg.AccessControl.Teams))
}
team := cfg.AccessControl.Teams[0]
if team.Name != "backend" || len(team.Bindings) != 1 {
t.Errorf("unexpected team: %+v", team)
}
if team.Bindings[0].BucketPrefixes[0] != "backend-" {
t.Errorf("prefix = %q", team.Bindings[0].BucketPrefixes[0])
}
if cfg.AccessControl.Presets["bucket_readonly"][0] != "bucket.list" {
t.Errorf("preset parse failed: %+v", cfg.AccessControl.Presets)
}
}
func TestAccessControlAbsentIsNil(t *testing.T) {
resetViper(t)
dir := t.TempDir()
cfgFile := filepath.Join(dir, "config.yaml")
yaml := `
garage:
endpoint: "http://localhost:3900"
admin_endpoint: "http://localhost:3903"
admin_token: "test-token"
`
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(cfgFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AccessControl != nil {
t.Fatalf("AccessControl = %+v, want nil when section absent", cfg.AccessControl)
}
}
func TestAccessControlPresentButEmptyIsNonNil(t *testing.T) {
// A present-but-empty access_control section pins the enablement
// semantics documented on AccessControlConfig: presence, not content,
// turns on default-deny. An operator who writes "access_control: {}"
// (e.g. while staging a config) must get a non-nil, enabled policy, not
// silently fall back to "every authenticated user is admin".
resetViper(t)
dir := t.TempDir()
cfgFile := filepath.Join(dir, "config.yaml")
yaml := `
garage:
endpoint: "http://localhost:3900"
admin_endpoint: "http://localhost:3903"
admin_token: "test-token"
access_control: {}
`
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(cfgFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AccessControl == nil {
t.Fatal("AccessControl = nil, want non-nil when section is present but empty")
}
if len(cfg.AccessControl.Teams) != 0 {
t.Errorf("Teams = %+v, want empty", cfg.AccessControl.Teams)
}
}
func TestOIDCAdminRolesOptionalWithAccessControl(t *testing.T) {
// With access_control present, OIDC no longer requires admin_role:
// default-deny protects unmatched users.
cfg := &Config{
Server: ServerConfig{Port: 8080, RootURL: "https://ui.example.com"},
Garage: GarageConfig{Endpoint: "e", AdminEndpoint: "a", AdminToken: "t"},
Auth: AuthConfig{OIDC: OIDCConfig{
Enabled: true, ClientID: "id", IssuerURL: "https://idp", Scopes: []string{"openid"},
}},
AccessControl: &AccessControlConfig{},
}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate with access_control and no admin_role: %v, want nil", err)
}
cfg.AccessControl = nil
if err := cfg.Validate(); err == nil {
t.Error("Validate without access_control and no admin_role should fail")
}
}
func TestIsProduction(t *testing.T) {
tests := []struct {
env string
@@ -894,49 +394,3 @@ func TestIsProduction(t *testing.T) {
})
}
}
func TestLoad_MetricsPublic_DefaultsFalse(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = true, want false by default")
}
}
func TestLoad_MetricsPublic_YAML(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML+`
auth:
metrics_public: true
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = false, want true from YAML")
}
}
func TestLoad_MetricsPublic_EnvOverridesYAML(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML+`
auth:
metrics_public: false
`)
t.Setenv("GARAGE_UI_AUTH_METRICS_PUBLIC", "true")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = false, want true (env should override YAML)")
}
}
-105
View File
@@ -1,105 +0,0 @@
package config
import (
"fmt"
"net"
"os"
"strings"
toml "github.com/pelletier/go-toml/v2"
)
// GarageTomlResult holds the values extracted from a garage.toml file.
type GarageTomlResult struct {
Endpoint string
AdminEndpoint string
AdminToken string
Region string
}
// garageTomlFile represents the subset of garage.toml we care about.
type garageTomlFile struct {
S3API struct {
APIBindAddr string `toml:"api_bind_addr"`
S3Region string `toml:"s3_region"`
} `toml:"s3_api"`
Admin struct {
APIBindAddr string `toml:"api_bind_addr"`
AdminToken string `toml:"admin_token"`
} `toml:"admin"`
}
// ParseGarageToml reads a garage.toml file and extracts the values needed
// for garage-ui configuration.
func ParseGarageToml(path string) (*GarageTomlResult, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading garage.toml: %w", err)
}
var f garageTomlFile
if err := toml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parsing garage.toml: %w", err)
}
if f.Admin.AdminToken == "" {
return nil, fmt.Errorf("garage.toml: [admin].admin_token is required")
}
if f.Admin.APIBindAddr == "" {
return nil, fmt.Errorf("garage.toml: [admin].api_bind_addr is required")
}
if f.S3API.APIBindAddr == "" {
return nil, fmt.Errorf("garage.toml: [s3_api].api_bind_addr is required")
}
adminEndpoint, err := convertBindAddr(f.Admin.APIBindAddr)
if err != nil {
return nil, fmt.Errorf("garage.toml: converting admin api_bind_addr: %w", err)
}
s3Endpoint, err := convertBindAddr(f.S3API.APIBindAddr)
if err != nil {
return nil, fmt.Errorf("garage.toml: converting s3_api api_bind_addr: %w", err)
}
region := f.S3API.S3Region
if region == "" {
region = "garage"
}
return &GarageTomlResult{
Endpoint: s3Endpoint,
AdminEndpoint: adminEndpoint,
AdminToken: f.Admin.AdminToken,
Region: region,
}, nil
}
// convertBindAddr converts a bind address like "[::]:3900" into an HTTP
// endpoint like "http://127.0.0.1:3900". Wildcard hosts (::, 0.0.0.0, empty)
// are replaced with 127.0.0.1.
func convertBindAddr(bindAddr string) (string, error) {
if bindAddr == "" {
return "", fmt.Errorf("bind address is empty")
}
host, port, err := net.SplitHostPort(bindAddr)
if err != nil {
return "", fmt.Errorf("invalid bind address %q: %w", bindAddr, err)
}
if port == "" {
return "", fmt.Errorf("bind address %q has no port", bindAddr)
}
switch host {
case "", "::", "0.0.0.0":
host = "127.0.0.1"
}
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return fmt.Sprintf("http://%s:%s", host, port), nil
}
-165
View File
@@ -1,165 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeTomlFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "garage.toml")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write toml: %v", err)
}
return path
}
const validGarageToml = `
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "my-secret-admin-token"
`
func TestParseGarageToml_ValidFile(t *testing.T) {
path := writeTomlFile(t, validGarageToml)
result, err := ParseGarageToml(path)
if err != nil {
t.Fatalf("ParseGarageToml: %v", err)
}
if result.AdminToken != "my-secret-admin-token" {
t.Errorf("AdminToken = %q, want my-secret-admin-token", result.AdminToken)
}
if result.AdminEndpoint != "http://127.0.0.1:3903" {
t.Errorf("AdminEndpoint = %q, want http://127.0.0.1:3903", result.AdminEndpoint)
}
if result.Endpoint != "http://127.0.0.1:3900" {
t.Errorf("Endpoint = %q, want http://127.0.0.1:3900", result.Endpoint)
}
if result.Region != "garage" {
t.Errorf("Region = %q, want garage", result.Region)
}
}
func TestParseGarageToml_MissingAdminToken(t *testing.T) {
toml := `
[admin]
api_bind_addr = "[::]:3903"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing admin_token, got nil")
}
}
func TestParseGarageToml_MissingAdminBindAddr(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing admin api_bind_addr, got nil")
}
}
func TestParseGarageToml_MissingS3BindAddr(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
api_bind_addr = "[::]:3903"
[s3_api]
s3_region = "garage"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing s3_api api_bind_addr, got nil")
}
}
func TestParseGarageToml_DefaultRegion(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
api_bind_addr = "[::]:3903"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
result, err := ParseGarageToml(path)
if err != nil {
t.Fatalf("ParseGarageToml: %v", err)
}
if result.Region != "garage" {
t.Errorf("Region = %q, want garage (default)", result.Region)
}
}
func TestParseGarageToml_FileNotFound(t *testing.T) {
_, err := ParseGarageToml("/nonexistent/garage.toml")
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
func TestConvertBindAddr(t *testing.T) {
tests := []struct {
name string
bindAddr string
want string
wantErr bool
}{
{"ipv6 wildcard", "[::]:3900", "http://127.0.0.1:3900", false},
{"ipv4 wildcard", "0.0.0.0:3900", "http://127.0.0.1:3900", false},
{"localhost", "127.0.0.1:3900", "http://127.0.0.1:3900", false},
{"specific ipv4", "192.168.1.1:3900", "http://192.168.1.1:3900", false},
{"ipv6 localhost", "[::1]:3900", "http://[::1]:3900", false},
{"specific ipv6", "[2001:db8::1]:3900", "http://[2001:db8::1]:3900", false},
{"empty host", ":3900", "http://127.0.0.1:3900", false},
{"empty string", "", "", true},
{"no port", "127.0.0.1", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := convertBindAddr(tc.bindAddr)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("convertBindAddr(%q) = %q, want %q", tc.bindAddr, got, tc.want)
}
})
}
}
+1 -49
View File
@@ -1,8 +1,6 @@
package handlers
import (
"crypto/subtle"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
@@ -40,9 +38,6 @@ func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
"oidc": fiber.Map{
"enabled": h.cfg.Auth.OIDC.Enabled,
},
"token": fiber.Map{
"enabled": h.cfg.Auth.Token.Enabled,
},
}
// Add provider name if OIDC is enabled
@@ -93,8 +88,7 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
// Create user info object
userInfo := &auth.UserInfo{
Username: req.Username,
AuthMethod: "admin",
Username: req.Username,
}
// Generate JWT session token
@@ -114,48 +108,6 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
})
}
// LoginTokenRequest represents the token auth login request
type LoginTokenRequest struct {
Token string `json:"token" validate:"required"`
}
// LoginToken handles admin token authentication login
func (h *AuthHandler) LoginToken(c fiber.Ctx) error {
var req LoginTokenRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"),
)
}
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(h.cfg.Garage.AdminToken), []byte(req.Token)) != 1 {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid admin token"),
)
}
userInfo := &auth.UserInfo{
Username: "admin-token",
AuthMethod: "token",
}
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create session"),
)
}
return c.JSON(fiber.Map{
"success": true,
"token": sessionToken,
"user": fiber.Map{
"username": userInfo.Username,
},
})
}
// GetMe returns the current authenticated user's information
//
// @Summary Get current user
-133
View File
@@ -250,139 +250,6 @@ func TestLoginAdmin_MalformedJSONReturns400(t *testing.T) {
}
}
func TestLoginToken_Success(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
body := `{"token":"test-admin-token"}`
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d, want 200\nbody: %s", resp.StatusCode, raw)
}
var decoded struct {
Success bool `json:"success"`
Token string `json:"token"`
User struct {
Username string `json:"username"`
} `json:"user"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
t.Fatalf("decode: %v", err)
}
if !decoded.Success {
t.Error("success = false")
}
if decoded.Token == "" {
t.Error("token empty")
}
if decoded.User.Username != "admin-token" {
t.Errorf("username = %q, want admin-token", decoded.User.Username)
}
}
func TestLoginToken_WrongToken(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
body := `{"token":"wrong-token"}`
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestLoginToken_MalformedJSONReturns400(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.NewReader("{not-json"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestGetAuthConfig_TokenEnabled(t *testing.T) {
cfg := &config.Config{
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
app, _ := newAuthTestApp(t, cfg)
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
var body struct {
Token struct {
Enabled bool `json:"enabled"`
} `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.Token.Enabled {
t.Error("token.enabled = false, want true")
}
}
func TestGetMe_OIDCUserInfoLocal(t *testing.T) {
cfg := &config.Config{Auth: config.AuthConfig{}}
app, h := newAuthTestApp(t, cfg)
+12 -129
View File
@@ -1,7 +1,7 @@
package handlers
import (
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -38,9 +38,7 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
// List all buckets from Garage Admin API
adminBuckets, err := h.adminService.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert admin bucket response to BucketInfo
@@ -75,16 +73,11 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
Size: &detailedInfo.Bytes,
WebsiteAccess: detailedInfo.WebsiteAccess,
WebsiteConfig: detailedInfo.WebsiteConfig,
Quotas: detailedInfo.Quotas,
}
buckets = append(buckets, bucketInfo)
}
if subj, ok := authz.SubjectFrom(c); ok {
buckets = filterBucketsForSubject(buckets, subj)
}
response := models.BucketListResponse{
Buckets: buckets,
Count: len(buckets),
@@ -130,9 +123,7 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
}
if _, err := h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return success response
@@ -171,9 +162,7 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -184,9 +173,7 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
// Delete the bucket
if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return success response
@@ -225,9 +212,7 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -236,10 +221,6 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
)
}
if subj, ok := authz.SubjectFrom(c); ok {
bucketInfo.EffectivePermissions = authz.EffectivePermissions(subj, bucketName)
}
return c.JSON(models.SuccessResponse(bucketInfo))
}
@@ -286,9 +267,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
// Get bucket info to retrieve bucket ID
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -320,9 +299,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
Permissions: allow,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
return apierr.Respond(c, err)
}
result = r
}
@@ -334,9 +311,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
Permissions: deny,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to revoke permissions: "+err.Error()),
)
return apierr.Respond(c, err)
}
result = r
}
@@ -346,9 +321,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
// Fetch current bucket state to return a consistent response.
r, err := h.adminService.GetBucketInfo(ctx, bucketInfo.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to fetch bucket info: "+err.Error()),
)
return apierr.Respond(c, err)
}
result = r
}
@@ -395,9 +368,7 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -422,96 +393,8 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket website: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(result))
}
// UpdateBucketQuotas updates the quota settings for a bucket
//
// @Summary Update bucket quotas
// @Description Sets or clears the max size (bytes) and max object count quotas for a bucket. A null field clears that quota (unlimited).
// @Tags Buckets
// @Accept json
// @Produce json
// @Param name path string true "Name of the bucket"
// @Param request body models.UpdateBucketQuotasRequest true "Quota configuration"
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Quotas updated"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update bucket"
// @Router /api/v1/buckets/{name}/quotas [put]
func (h *BucketHandler) UpdateBucketQuotas(c fiber.Ctx) error {
ctx := c.Context()
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
var req models.UpdateBucketQuotasRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
if req.MaxSize != nil && *req.MaxSize <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "maxSize must be greater than 0"),
)
}
if req.MaxObjects != nil && *req.MaxObjects <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "maxObjects must be greater than 0"),
)
}
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
}
if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
updateReq := models.UpdateBucketRequest{
Quotas: &models.BucketQuotas{
MaxSize: req.MaxSize,
MaxObjects: req.MaxObjects,
},
}
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket quotas: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(result))
}
// filterBucketsForSubject applies the access-control view of a bucket list:
// a bucket is visible iff the subject holds bucket.list for it, and each
// visible bucket carries the subject's effective permissions.
func filterBucketsForSubject(buckets []models.BucketInfo, subj authz.Subject) []models.BucketInfo {
out := make([]models.BucketInfo, 0, len(buckets))
for _, b := range buckets {
if !authz.Decide(subj, authz.PermBucketList, authz.Resource{Bucket: b.Name}).Allow {
continue
}
b.EffectivePermissions = authz.EffectivePermissions(subj, b.Name)
out = append(out, b)
}
return out
}
@@ -1,46 +0,0 @@
package handlers
import (
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/models"
)
func teamSubject() authz.Subject {
return authz.Subject{
ID: "alice",
Bindings: []authz.Binding{{
BucketPrefixes: []string{"backend-"},
Permissions: authz.PermSet{"bucket.list": {}, "bucket.read": {}, "object.read": {}},
}},
}
}
func TestFilterBucketsForSubject(t *testing.T) {
buckets := []models.BucketInfo{
{Name: "backend-api"},
{Name: "backend-assets"},
{Name: "data-warehouse"},
}
got := filterBucketsForSubject(buckets, teamSubject())
if len(got) != 2 {
t.Fatalf("filtered to %d buckets, want 2", len(got))
}
for _, b := range got {
if b.Name == "data-warehouse" {
t.Error("data-warehouse must be filtered out")
}
if len(b.EffectivePermissions) == 0 {
t.Errorf("%s: effective_permissions must be populated", b.Name)
}
}
}
func TestFilterBucketsAdminSeesAll(t *testing.T) {
buckets := []models.BucketInfo{{Name: "a"}, {Name: "b"}}
got := filterBucketsForSubject(buckets, authz.AdminSubject("root"))
if len(got) != 2 {
t.Fatalf("admin sees %d buckets, want 2", len(got))
}
}
@@ -1,45 +0,0 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
func TestGetBucketInfoPopulatesEffectivePermissions(t *testing.T) {
admin := &mocks.AdminMock{}
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
h := NewBucketHandler(admin, nil)
app := fiber.New()
app.Get("/buckets/:name", func(c fiber.Ctx) error {
c.Locals(authz.SubjectLocalsKey, teamSubject())
return h.GetBucketInfo(c)
})
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/backend-api", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.GarageBucketInfo `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if len(body.Data.EffectivePermissions) == 0 {
t.Error("effective_permissions must be populated for a subject in scope of the bucket")
}
}
+22 -181
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
@@ -29,7 +30,6 @@ func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
app.Delete("/buckets/:name", h.DeleteBucket)
app.Post("/buckets/:name/permissions", h.GrantBucketPermission)
app.Put("/buckets/:name/website", h.UpdateBucketWebsite)
app.Put("/buckets/:name/quotas", h.UpdateBucketQuotas)
return app, admin
}
@@ -428,195 +428,36 @@ func TestUpdateBucketWebsite_Disable(t *testing.T) {
}
}
func TestUpdateBucketQuotas_SetBoth(t *testing.T) {
func TestDeleteBucket_UpstreamBucketNotEmpty(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
if req.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
admin.DeleteBucketFn = func(_ context.Context, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 409,
Code: "BucketNotEmpty",
Message: "Tried to delete a non-empty bucket",
Source: "garage",
}
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 53687091200 {
t.Errorf("MaxSize = %v, want 53687091200", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 10000 {
t.Errorf("MaxObjects = %v, want 10000", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxSize := int64(53687091200)
maxObjects := int64(10000)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize, MaxObjects: &maxObjects})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_SetOnlyMaxSize(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
if req.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
}
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 1024 {
t.Errorf("MaxSize = %v, want 1024", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects != nil {
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxSize := int64(1024)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_SetOnlyMaxObjects(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
if req.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
}
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 500 {
t.Errorf("MaxObjects = %v, want 500", req.Quotas.MaxObjects)
}
if req.Quotas.MaxSize != nil {
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxObjects := int64(500)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &maxObjects})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_ClearBoth(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
if req.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil (envelope must be present so service clears both)")
}
if req.Quotas.MaxSize != nil {
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects != nil {
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id}, nil
}
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_RejectsZeroMaxSize(t *testing.T) {
app, _ := newBucketsTestApp(t)
zero := int64(0)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &zero})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_RejectsNegativeMaxObjects(t *testing.T) {
app, _ := newBucketsTestApp(t)
neg := int64(-1)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &neg})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_NotFound(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return nil, nil
}
maxSize := int64(1024)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
req := httptest.NewRequest(http.MethodPut, "/buckets/missing/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_MalformedJSONReturns400(t *testing.T) {
app, _ := newBucketsTestApp(t)
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader([]byte("{not json")))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil {
t.Fatal("error missing from response body")
}
if body.Error.Code != models.ErrCodeBucketNotEmpty {
t.Errorf("code = %q, want %s", body.Error.Code, models.ErrCodeBucketNotEmpty)
}
if body.Error.Message != "Tried to delete a non-empty bucket" {
t.Errorf("message = %q", body.Error.Message)
}
}
-74
View File
@@ -1,74 +0,0 @@
package handlers
import (
"sort"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
type CapabilitiesHandler struct {
apiVersion string
capabilities services.Capabilities
accessControlEnabled bool
}
func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities, accessControlEnabled bool) *CapabilitiesHandler {
return &CapabilitiesHandler{
apiVersion: apiVersion,
capabilities: capabilities,
accessControlEnabled: accessControlEnabled,
}
}
// accessControlBinding mirrors one compiled binding, unflattened: "read on
// backend-*" plus "write on data-*" must never merge into both-on-both.
type accessControlBinding struct {
BucketPrefixes []string `json:"bucket_prefixes"`
Permissions []string `json:"permissions"`
}
type accessControlBlock struct {
Enabled bool `json:"enabled"`
Subject string `json:"subject,omitempty"`
IsAdmin bool `json:"is_admin,omitempty"`
Bindings []accessControlBinding `json:"bindings,omitempty"`
ClusterPermissions []string `json:"cluster_permissions,omitempty"`
}
func (h *CapabilitiesHandler) GetCapabilities(c fiber.Ctx) error {
ac := accessControlBlock{Enabled: h.accessControlEnabled}
if h.accessControlEnabled {
if subj, ok := authz.SubjectFrom(c); ok {
ac.Subject = subj.ID
ac.IsAdmin = subj.IsAdmin
for _, b := range subj.Bindings {
ac.Bindings = append(ac.Bindings, accessControlBinding{
BucketPrefixes: b.BucketPrefixes,
Permissions: sortedPerms(b.Permissions),
})
}
ac.ClusterPermissions = sortedPerms(subj.ClusterPerms)
}
}
return c.JSON(models.SuccessResponse(fiber.Map{
"garageApiVersion": h.apiVersion,
"features": h.capabilities,
"access_control": ac,
}))
}
func sortedPerms(set authz.PermSet) []string {
if len(set) == 0 {
return nil
}
out := make([]string, 0, len(set))
for p := range set {
out = append(out, p)
}
sort.Strings(out)
return out
}
@@ -1,161 +0,0 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
func TestCapabilities_V2(t *testing.T) {
app := fiber.New()
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false)
app.Get("/capabilities", h.GetCapabilities)
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Success bool `json:"success"`
Data struct {
GarageApiVersion string `json:"garageApiVersion"`
Features services.Capabilities `json:"features"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if !body.Success {
t.Fatal("expected success=true")
}
if body.Data.GarageApiVersion != "v2" {
t.Errorf("garageApiVersion = %q, want v2", body.Data.GarageApiVersion)
}
if !body.Data.Features.ClusterStatistics || !body.Data.Features.NodeInfo || !body.Data.Features.NodeStatistics {
t.Errorf("features = %+v, want all true", body.Data.Features)
}
}
func TestCapabilities_V1(t *testing.T) {
app := fiber.New()
h := NewCapabilitiesHandler("v1", services.CapabilitiesV1(), false)
app.Get("/capabilities", h.GetCapabilities)
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body struct {
Data struct {
GarageApiVersion string `json:"garageApiVersion"`
Features services.Capabilities `json:"features"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Data.GarageApiVersion != "v1" {
t.Errorf("garageApiVersion = %q, want v1", body.Data.GarageApiVersion)
}
if body.Data.Features.ClusterStatistics || body.Data.Features.NodeInfo || body.Data.Features.NodeStatistics {
t.Errorf("features = %+v, want all false", body.Data.Features)
}
}
func TestSortedPermsEmptyReturnsNil(t *testing.T) {
if got := sortedPerms(authz.PermSet{}); got != nil {
t.Errorf("sortedPerms(empty) = %v, want nil", got)
}
if got := sortedPerms(nil); got != nil {
t.Errorf("sortedPerms(nil) = %v, want nil", got)
}
}
func TestGetCapabilitiesAccessControlDisabled(t *testing.T) {
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false)
app := fiber.New()
app.Get("/capabilities", h.GetCapabilities)
resp, err := app.Test(httptest.NewRequest("GET", "/capabilities", nil))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
var envelope struct {
Data struct {
AccessControl struct {
Enabled bool `json:"enabled"`
} `json:"access_control"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
if envelope.Data.AccessControl.Enabled {
t.Error("enabled should be false when access control is off")
}
}
func TestGetCapabilitiesAccessControlSubject(t *testing.T) {
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), true)
app := fiber.New()
app.Get("/capabilities", func(c fiber.Ctx) error {
c.Locals(authz.SubjectLocalsKey, authz.Subject{
ID: "alice@example.com",
Bindings: []authz.Binding{{
BucketPrefixes: []string{"backend-"},
Permissions: authz.PermSet{"bucket.list": {}, "bucket.read": {}},
}},
ClusterPerms: authz.PermSet{"cluster.status": {}},
})
return h.GetCapabilities(c)
})
resp, err := app.Test(httptest.NewRequest("GET", "/capabilities", nil))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
var envelope struct {
Data struct {
AccessControl struct {
Enabled bool `json:"enabled"`
Subject string `json:"subject"`
IsAdmin bool `json:"is_admin"`
ClusterPermissions []string `json:"cluster_permissions"`
Bindings []struct {
BucketPrefixes []string `json:"bucket_prefixes"`
Permissions []string `json:"permissions"`
} `json:"bindings"`
} `json:"access_control"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
ac := envelope.Data.AccessControl
if !ac.Enabled || ac.Subject != "alice@example.com" || ac.IsAdmin {
t.Errorf("unexpected access_control header fields: %+v", ac)
}
if len(ac.Bindings) != 1 || len(ac.Bindings[0].Permissions) != 2 {
t.Errorf("bindings not mirrored unflattened: %+v", ac.Bindings)
}
if len(ac.ClusterPermissions) != 1 || ac.ClusterPermissions[0] != "cluster.status" {
t.Errorf("cluster_permissions = %v", ac.ClusterPermissions)
}
}
+8 -17
View File
@@ -1,8 +1,6 @@
package handlers
import (
"errors"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -71,17 +69,14 @@ func (h *ClusterHandler) GetStatus(c fiber.Ctx) error {
// GET /api/v1/cluster/statistics
func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
ctx := c.Context()
stats, err := h.adminService.GetClusterStatistics(ctx)
if err != nil {
if errors.Is(err, services.ErrUnsupported) {
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
)
}
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(stats))
}
@@ -100,22 +95,20 @@ func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
ctx := c.Context()
nodeID := c.Params("node_id")
if nodeID == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
)
}
info, err := h.adminService.GetNodeInfo(ctx, nodeID)
if err != nil {
if errors.Is(err, services.ErrUnsupported) {
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
)
}
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(info))
}
@@ -134,21 +127,19 @@ func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error {
ctx := c.Context()
nodeID := c.Params("node_id")
if nodeID == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
)
}
stats, err := h.adminService.GetNodeStatistics(ctx, nodeID)
if err != nil {
if errors.Is(err, services.ErrUnsupported) {
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
)
}
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(stats))
}
-37
View File
@@ -9,7 +9,6 @@ import (
"testing"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
@@ -125,42 +124,6 @@ func TestCluster_GetStatistics_ServiceErrorReturns500(t *testing.T) {
}
}
func TestCluster_GetStatistics_UnsupportedReturns501(t *testing.T) {
app, admin := newClusterTestApp(t)
admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) {
return nil, services.ErrUnsupported
}
resp := doGet(t, app, "/cluster/statistics")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501", resp.StatusCode)
}
}
func TestCluster_GetNodeInfo_UnsupportedReturns501(t *testing.T) {
app, admin := newClusterTestApp(t)
admin.GetNodeInfoFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
return nil, services.ErrUnsupported
}
resp := doGet(t, app, "/cluster/nodes/n1")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501", resp.StatusCode)
}
}
func TestCluster_GetNodeStatistics_UnsupportedReturns501(t *testing.T) {
app, admin := newClusterTestApp(t)
admin.GetNodeStatisticsFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
return nil, services.ErrUnsupported
}
resp := doGet(t, app, "/cluster/nodes/n1/statistics")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501", resp.StatusCode)
}
}
func TestCluster_GetNodeInfo_Success(t *testing.T) {
app, admin := newClusterTestApp(t)
admin.GetNodeInfoFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) {
+44 -228
View File
@@ -1,6 +1,7 @@
package handlers
import (
"bufio"
"io"
"net/url"
"path"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -19,13 +21,13 @@ import (
// served from the same origin as the API, any uploader could otherwise plant
// stored XSS by uploading a file with one of these Content-Types.
var unsafeInlineContentTypes = map[string]struct{}{
"text/html": {},
"application/xhtml+xml": {},
"image/svg+xml": {},
"application/xml": {},
"text/xml": {},
"text/html": {},
"application/xhtml+xml": {},
"image/svg+xml": {},
"application/xml": {},
"text/xml": {},
"application/javascript": {},
"text/javascript": {},
"text/javascript": {},
}
// safeContentType rewrites Content-Types that the browser would treat as
@@ -67,27 +69,15 @@ func contentDispositionHeader(disposition, key string) string {
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
}
// PreviewTokenMinter mints signed single-object preview tokens.
// auth.Service satisfies it.
type PreviewTokenMinter interface {
MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error)
}
// previewTokenTTL is long enough that seeking mid-playback keeps working.
// The frontend mints a fresh URL when a token expires.
const previewTokenTTL = time.Hour
// ObjectHandler handles object-related HTTP requests.
type ObjectHandler struct {
s3Service services.S3Storage
previewTokens PreviewTokenMinter
s3Service services.S3Storage
}
// NewObjectHandler creates a new object handler.
func NewObjectHandler(s3Service services.S3Storage, previewTokens PreviewTokenMinter) *ObjectHandler {
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
return &ObjectHandler{
s3Service: s3Service,
previewTokens: previewTokens,
s3Service: s3Service,
}
}
@@ -100,7 +90,6 @@ func NewObjectHandler(s3Service services.S3Storage, previewTokens PreviewTokenMi
// @Produce json
// @Param bucket path string true "Name of the bucket to list objects from"
// @Param prefix query string false "Filter objects by prefix"
// @Param search query string false "Recursively search object keys under prefix by case-insensitive substring (best-effort; bypasses max_keys and continuation_token)"
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
@@ -121,21 +110,6 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
// Get query parameters for filtering and pagination
prefix := c.Query("prefix", "")
// Search mode: a recursive, best-effort substring search across the whole
// subtree under prefix. S3/Garage has no server-side substring search, so
// the backend scans and filters. This bypasses page-token pagination and
// max_keys, see S3Service.SearchObjects -> pagination is handled frontend side.
if search := c.Query("search", ""); search != "" {
results, err := h.s3Service.SearchObjects(ctx, bucketName, prefix, search)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to search objects: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(results))
}
continuationToken := c.Query("continuation_token", "")
maxKeysStr := c.Query("max_keys", "100")
@@ -149,9 +123,7 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
// List objects in the bucket
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(objects))
@@ -213,9 +185,7 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
// Upload to Garage
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
@@ -265,9 +235,7 @@ func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
result, err := h.s3Service.CreateDirectoryMarker(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to create directory: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(result))
@@ -288,8 +256,12 @@ func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameters
bucketName := c.Params("bucket")
// Get object key from locals (set by route handler) or from params
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
@@ -301,22 +273,10 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
)
}
// Range requests stream a partial body so media elements can seek.
if rangeHeader := c.Get("Range"); rangeHeader != "" {
return h.getObjectRange(c, bucketName, key, rangeHeader)
}
return h.serveFullObject(c, bucketName, key)
}
// serveFullObject streams the whole object with a 200, the pre-Range behavior.
func (h *ObjectHandler) serveFullObject(c fiber.Ctx, bucketName, key string) error {
ctx := c.Context()
// Get object from Garage
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
return apierr.Respond(c, err)
}
// The uploader controls Content-Type. Rewrite executable MIME types to
@@ -324,12 +284,11 @@ func (h *ObjectHandler) serveFullObject(c fiber.Ctx, bucketName, key string) err
// cannot run as XSS in the SPA origin when fetched inline.
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// The object key is attacker-controlled. Build the header via the safe
// The object key is attacker-controlled — build the header via the safe
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
disposition := "inline"
if c.Query("download") == "true" {
@@ -337,60 +296,11 @@ func (h *ObjectHandler) serveFullObject(c fiber.Ctx, bucketName, key string) err
}
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
// streaming writer variant forces fasthttp into unknown-length chunked
// transfer, dropping the header we just set above.
return c.SendStream(body, int(objectInfo.Size))
}
// getObjectRange serves a single-range request with 206 Partial Content.
// Malformed and multi-range headers fall back to the full 200 response.
func (h *ObjectHandler) getObjectRange(c fiber.Ctx, bucketName, key, rangeHeader string) error {
ctx := c.Context()
info, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
rng, unsatisfiable := parseRangeHeader(rangeHeader, info.Size)
if unsatisfiable {
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Range", "bytes */"+strconv.FormatInt(info.Size, 10))
return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
}
if rng == nil {
return h.serveFullObject(c, bucketName, key)
}
body, err := h.s3Service.GetObjectRange(ctx, bucketName, key, rng.start, rng.end)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
c.Set("Content-Type", safeContentType(info.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Length", strconv.FormatInt(rng.end-rng.start+1, 10))
c.Set("Content-Range", "bytes "+strconv.FormatInt(rng.start, 10)+"-"+strconv.FormatInt(rng.end, 10)+"/"+strconv.FormatInt(info.Size, 10))
c.Set("ETag", info.ETag)
c.Set("Last-Modified", info.LastModified.Format(time.RFC1123))
disposition := "inline"
if c.Query("download") == "true" {
disposition = "attachment"
}
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
c.Status(fiber.StatusPartialContent)
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
// streaming writer variant forces fasthttp into unknown-length chunked
// transfer, dropping the header we just set above.
return c.SendStream(body, int(rng.end-rng.start+1))
// Stream the object body to the client without buffering the entire file
return c.SendStreamWriter(func(w *bufio.Writer) {
defer body.Close()
io.Copy(w, body)
})
}
// DeleteObject deletes an object from a bucket
@@ -428,9 +338,7 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if !exists {
@@ -441,9 +349,7 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
// Delete the object
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return success response
@@ -490,12 +396,9 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
// Get object metadata
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
return apierr.Respond(c, err)
}
c.Set("Accept-Ranges", "bytes")
return c.JSON(models.SuccessResponse(metadata))
}
@@ -551,9 +454,7 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if !exists {
@@ -565,9 +466,7 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
// Generate pre-signed URL
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
)
return apierr.Respond(c, err)
}
response := models.PresignedURLResponse{
@@ -580,48 +479,6 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(response))
}
// GetPreviewURL mints a short-lived tokenized URL for streaming this object
//
// @Summary Get a tokenized preview URL for an object
// @Description Returns a relative URL carrying a short-lived token that authorizes streaming this object. Media elements cannot send an Authorization header, so the token rides in the URL instead.
// @Tags Objects
// @Produce json
// @Param bucket path string true "Name of the bucket containing the object"
// @Param key path string true "Key (path) of the object"
// @Success 200 {object} models.APIResponse{data=models.PreviewURLResponse} "Preview URL minted"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to mint the preview token"
// @Router /api/v1/buckets/{bucket}/objects/{key}/preview-url [get]
func (h *ObjectHandler) GetPreviewURL(c fiber.Ctx) error {
bucketName := c.Params("bucket")
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
}
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
token, expiresAt, err := h.previewTokens.MintPreviewToken(bucketName, key, previewTokenTTL)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to mint the preview token: "+err.Error()),
)
}
previewURL := "/api/v1/buckets/" + url.PathEscape(bucketName) +
"/objects/" + url.PathEscape(key) + "?pt=" + url.QueryEscape(token)
return c.JSON(models.SuccessResponse(models.PreviewURLResponse{
URL: previewURL,
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
}))
}
// DeleteMultipleObjects deletes multiple objects from a bucket
//
// @Summary Delete multiple objects from bucket
@@ -629,8 +486,8 @@ func (h *ObjectHandler) GetPreviewURL(c fiber.Ctx) error {
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket containing the objects"
// @Param request body object{keys=[]string,prefixes=[]string} true "Object keys to delete and/or folder prefixes to delete recursively"
// @Param bucket path string true "Name of the bucket containing the objects"
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
@@ -647,11 +504,10 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
)
}
// Parse request body. "keys" are concrete objects to delete; "prefixes" are
// folders to delete recursively (every object stored under the prefix).
// Parse request body to get keys and optional prefix
var req struct {
Keys []string `json:"keys"`
Prefixes []string `json:"prefixes,omitempty"`
Keys []string `json:"keys"`
Prefix string `json:"prefix,omitempty"`
}
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -659,61 +515,21 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
)
}
if len(req.Keys) == 0 && len(req.Prefixes) == 0 {
if len(req.Keys) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key or prefix is required"),
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
)
}
// Validate and normalize folder prefixes before running an irreversible
// recursive delete on a public endpoint. A blank prefix would match the
// entire bucket, and a prefix without a trailing slash (e.g. "photos/2024")
// would also match sibling keys such as "photos/2024-old/...". Reject blanks
// with a 4XX and force a trailing slash so a prefix only ever deletes the
// objects inside its own folder.
prefixes := make([]string, 0, len(req.Prefixes))
for _, p := range req.Prefixes {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Prefix must not be blank"),
)
}
if !strings.HasSuffix(trimmed, "/") {
trimmed += "/"
}
prefixes = append(prefixes, trimmed)
}
deleted := 0
// Delete the individually selected objects in a single batch call.
if len(req.Keys) > 0 {
n, err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
}
deleted += n
}
// Recursively delete every object under each selected folder prefix.
for _, prefix := range prefixes {
n, err := h.s3Service.DeleteObjectsByPrefix(ctx, bucketName, prefix)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete folder "+prefix+": "+err.Error()),
)
}
deleted += n
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return apierr.Respond(c, err)
}
response := models.ObjectDeleteMultipleResponse{
Bucket: bucketName,
Deleted: deleted,
Keys: req.Keys,
Prefixes: prefixes,
Bucket: bucketName,
Deleted: len(req.Keys),
Keys: req.Keys,
}
return c.JSON(models.SuccessResponse(response))
+34 -474
View File
@@ -13,6 +13,7 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
@@ -20,42 +21,23 @@ import (
"github.com/gofiber/fiber/v3"
)
// mintStub satisfies PreviewTokenMinter for handler tests.
type mintStub struct {
fn func(bucket, key string, ttl time.Duration) (string, time.Time, error)
}
func (m *mintStub) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
if m.fn == nil {
return "test-token", time.Now().Add(ttl), nil
}
return m.fn(bucket, key, ttl)
}
func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
app, s3, _ := newObjectsTestAppWithMinter(t)
return app, s3
}
func newObjectsTestAppWithMinter(t *testing.T) (*fiber.App, *mocks.S3Mock, *mintStub) {
t.Helper()
s3 := &mocks.S3Mock{}
minter := &mintStub{}
h := NewObjectHandler(s3, minter)
h := NewObjectHandler(s3)
app := fiber.New()
app.Get("/buckets/:bucket/objects", h.ListObjects)
app.Post("/buckets/:bucket/objects", h.UploadObject)
app.Post("/buckets/:bucket/directories", h.CreateDirectory)
app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects)
app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects)
// Wildcard endpoints. Mount under :key for tests. Handlers prefer
// Wildcard endpoints — mount under :key for tests. Handlers prefer
// c.Locals("objectKey") but fall back to c.Params("key"), so :key works.
app.Get("/buckets/:bucket/objects/:key", h.GetObject)
app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata)
app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL)
app.Get("/buckets/:bucket/objects/:key/preview-url", h.GetPreviewURL)
app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject)
return app, s3, minter
return app, s3
}
// --- ListObjects ---
@@ -139,52 +121,6 @@ func TestListObjects_ServiceError500(t *testing.T) {
}
}
func TestListObjects_SearchRoutesToSearchObjects(t *testing.T) {
app, s3 := newObjectsTestApp(t)
// Intentionally leave ListObjectsFn unset: if the handler wrongly falls
// through to a normal listing, the mock returns an error and this fails.
s3.SearchObjectsFn = func(_ context.Context, bucket, prefix, search string) (*models.ObjectListResponse, error) {
if bucket != "b1" || prefix != "docs/" || search != "target" {
t.Errorf("args = (%q, %q, %q)", bucket, prefix, search)
}
return &models.ObjectListResponse{
Bucket: bucket, Count: 1,
Objects: []models.ObjectInfo{{Key: "docs/target.pdf", Size: 20}},
}, nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=docs/&search=target", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var body struct {
Data models.ObjectListResponse `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if body.Data.Count != 1 || len(body.Data.Objects) != 1 || body.Data.Objects[0].Key != "docs/target.pdf" {
t.Errorf("unexpected search results: %+v", body.Data)
}
}
func TestListObjects_SearchError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.SearchObjectsFn = func(_ context.Context, _, _, _ string) (*models.ObjectListResponse, error) {
return nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?search=target", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- GetObjectMetadata ---
func TestGetObjectMetadata_Success(t *testing.T) {
@@ -209,18 +145,20 @@ func TestGetObjectMetadata_Success(t *testing.T) {
}
}
func TestGetObjectMetadata_NotFound404(t *testing.T) {
func TestGetObjectMetadata_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return nil, errors.New("not found")
return nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
// Generic errors map to 500/INTERNAL_ERROR; typed upstream NoSuchKey → 404
// is covered by Task 12 tests.
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
@@ -362,109 +300,6 @@ func TestGetPresignedURL_ObjectMissing404(t *testing.T) {
}
}
// --- GetPreviewURL ---
func TestGetPreviewURL_Success(t *testing.T) {
app, _, minter := newObjectsTestAppWithMinter(t)
fixed := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
minter.fn = func(bucket, key string, ttl time.Duration) (string, time.Time, error) {
if bucket != "b1" || key != "clip.mp4" {
t.Errorf("mint args = (%q, %q)", bucket, key)
}
if ttl != time.Hour {
t.Errorf("ttl = %v, want 1h", ttl)
}
return "tok123", fixed, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4/preview-url", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.PreviewURLResponse `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if body.Data.URL != "/api/v1/buckets/b1/objects/clip.mp4?pt=tok123" {
t.Errorf("url = %q", body.Data.URL)
}
if body.Data.ExpiresAt != "2026-07-11T12:00:00Z" {
t.Errorf("expires_at = %q", body.Data.ExpiresAt)
}
}
func TestGetPreviewURL_EscapesKeyInURL(t *testing.T) {
// Production sets the decoded key in locals via the wildcard dispatcher,
// so mirror that here instead of relying on :key param decoding.
s3 := &mocks.S3Mock{}
minter := &mintStub{}
minter.fn = func(_, key string, _ time.Duration) (string, time.Time, error) {
if key != "dir/my file.mp4" {
t.Errorf("key = %q", key)
}
return "tok", time.Now().Add(time.Hour), nil
}
h := NewObjectHandler(s3, minter)
app := fiber.New()
app.Get("/buckets/:bucket/preview-url", func(c fiber.Ctx) error {
c.Locals("objectKey", "dir/my file.mp4")
return h.GetPreviewURL(c)
})
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/preview-url", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.PreviewURLResponse `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if !strings.HasPrefix(body.Data.URL, "/api/v1/buckets/b1/objects/dir%2Fmy%20file.mp4?pt=") {
t.Errorf("url = %q, want the key percent-encoded whole", body.Data.URL)
}
}
func TestGetPreviewURL_MissingBucketAndKey400(t *testing.T) {
// Mount on a route with no :bucket param and no objectKey local, so both
// bucket and key are empty and the handler short-circuits with 400.
s3 := &mocks.S3Mock{}
minter := &mintStub{}
h := NewObjectHandler(s3, minter)
app := fiber.New()
app.Get("/preview-url-nobucket", h.GetPreviewURL)
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/preview-url-nobucket", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestGetPreviewURL_MintError500(t *testing.T) {
app, _, minter := newObjectsTestAppWithMinter(t)
minter.fn = func(_, _ string, _ time.Duration) (string, time.Time, error) {
return "", time.Time{}, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f/preview-url", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- GetObject ---
func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) {
@@ -531,18 +366,20 @@ func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) {
}
}
func TestGetObject_ServiceErrorReturns404(t *testing.T) {
func TestGetObject_ServiceErrorReturns500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return nil, nil, errors.New("not found")
return nil, nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
// Generic errors map to 500/INTERNAL_ERROR; typed upstream NoSuchKey → 404
// is covered by Task 12 tests.
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
@@ -687,11 +524,11 @@ func TestUploadObject_ServiceError500(t *testing.T) {
func TestDeleteMultipleObjects_Success(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) (int, error) {
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error {
if bucket != "b1" || len(keys) != 3 {
t.Errorf("args = (%q, %v)", bucket, keys)
}
return len(keys), nil
return nil
}
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
@@ -713,81 +550,6 @@ func TestDeleteMultipleObjects_Success(t *testing.T) {
}
}
func TestDeleteMultipleObjects_Prefixes_Recursive(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteObjectsByPrefixFn = func(_ context.Context, bucket, prefix string) (int, error) {
if bucket != "b1" || prefix != "docs/" {
t.Errorf("args = (%q, %q)", bucket, prefix)
}
return 4, nil
}
body, _ := json.Marshal(map[string]any{"prefixes": []string{"docs/"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out struct {
Data models.ObjectDeleteMultipleResponse `json:"data"`
}
decodeJSON(t, resp.Body, &out)
if out.Data.Deleted != 4 {
t.Errorf("Deleted = %d, want 4", out.Data.Deleted)
}
}
func TestDeleteMultipleObjects_KeysAndPrefixes(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, keys []string) (int, error) {
if len(keys) != 2 {
t.Errorf("keys = %v", keys)
}
return len(keys), nil
}
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) { return 3, nil }
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b"}, "prefixes": []string{"docs/"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out struct {
Data models.ObjectDeleteMultipleResponse `json:"data"`
}
decodeJSON(t, resp.Body, &out)
if out.Data.Deleted != 5 {
t.Errorf("Deleted = %d, want 5 (2 keys + 3 under prefix)", out.Data.Deleted)
}
}
func TestDeleteMultipleObjects_PrefixError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) {
return 0, errors.New("boom")
}
body, _ := json.Marshal(map[string]any{"prefixes": []string{"docs/"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
app, _ := newObjectsTestApp(t)
body, _ := json.Marshal(map[string]any{"keys": []string{}})
@@ -803,54 +565,6 @@ func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
}
}
func TestDeleteMultipleObjects_BlankPrefix400(t *testing.T) {
// A blank/whitespace-only prefix must be rejected with a 4XX before any
// delete is attempted — it would otherwise target the whole bucket.
for _, prefix := range []string{"", " "} {
app, s3 := newObjectsTestApp(t)
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) {
t.Errorf("DeleteObjectsByPrefix must not be called for blank prefix %q", prefix)
return 0, nil
}
body, _ := json.Marshal(map[string]any{"prefixes": []string{prefix}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("prefix %q: status = %d, want 400", prefix, resp.StatusCode)
}
}
}
func TestDeleteMultipleObjects_PrefixNormalizedToTrailingSlash(t *testing.T) {
// A prefix without a trailing slash must be normalized so it only deletes
// its own folder ("photos/2024/"), not siblings like "photos/2024-old/".
app, s3 := newObjectsTestApp(t)
var gotPrefix string
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, prefix string) (int, error) {
gotPrefix = prefix
return 1, nil
}
body, _ := json.Marshal(map[string]any{"prefixes": []string{"photos/2024"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if gotPrefix != "photos/2024/" {
t.Errorf("prefix passed to service = %q, want %q", gotPrefix, "photos/2024/")
}
}
func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
app, _ := newObjectsTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json"))
@@ -867,7 +581,7 @@ func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
func TestDeleteMultipleObjects_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) (int, error) { return 0, errors.New("boom") }
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") }
body, _ := json.Marshal(map[string]any{"keys": []string{"a"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -1156,115 +870,18 @@ func TestCreateDirectory_ServiceError500(t *testing.T) {
}
}
// --- GetObject Range support ---
func TestGetObject_NoRangeHeaderAdvertisesAcceptRanges(t *testing.T) {
func TestDeleteObject_UpstreamNoSuchKey(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("0123456789")), &models.ObjectInfo{Key: key, Size: 10, ContentType: "text/plain", LastModified: time.Now()}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.txt", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
t.Errorf("Accept-Ranges = %q, want %q", got, "bytes")
}
}
func TestGetObject_RangeRequestServes206(t *testing.T) {
app, s3 := newObjectsTestApp(t)
now := time.Now()
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10, ContentType: "video/mp4", ETag: "e1", LastModified: now}, nil
}
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
if start != 2 || end != 6 {
t.Errorf("range = %d-%d, want 2-6", start, end)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
s3.DeleteObjectFn = func(_ context.Context, _, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 404,
Code: "NoSuchKey",
Message: "The specified key does not exist",
Source: "s3",
}
return io.NopCloser(strings.NewReader("23456")), nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4", nil)
req.Header.Set("Range", "bytes=2-6")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", resp.StatusCode)
}
if got := resp.Header.Get("Content-Range"); got != "bytes 2-6/10" {
t.Errorf("Content-Range = %q, want %q", got, "bytes 2-6/10")
}
if got := resp.Header.Get("Content-Length"); got != "5" {
t.Errorf("Content-Length = %q, want %q", got, "5")
}
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
t.Errorf("Accept-Ranges = %q, want %q", got, "bytes")
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "23456" {
t.Errorf("body = %q, want %q", body, "23456")
}
}
func TestGetObject_UnsatisfiableRangeServes416(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10}, nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.bin", nil)
req.Header.Set("Range", "bytes=50-")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("status = %d, want 416", resp.StatusCode)
}
if got := resp.Header.Get("Content-Range"); got != "bytes */10" {
t.Errorf("Content-Range = %q, want %q", got, "bytes */10")
}
}
func TestGetObject_MultiRangeFallsBackToFullResponse(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10}, nil
}
s3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("0123456789")), &models.ObjectInfo{Key: key, Size: 10, LastModified: time.Now()}, nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.bin", nil)
req.Header.Set("Range", "bytes=0-1,3-4")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "0123456789" {
t.Errorf("body = %q, want the full object", body)
}
}
func TestGetObject_RangeForMissingObjectIs404(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return nil, errors.New("no such key")
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/gone.bin", nil)
req.Header.Set("Range", "bytes=0-5")
resp, err := app.Test(req)
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/foo.txt", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
@@ -1272,69 +889,12 @@ func TestGetObject_RangeForMissingObjectIs404(t *testing.T) {
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
// A ranged read whose metadata resolves but whose byte fetch fails, for example
// when the object is deleted between the two calls, returns 404.
func TestGetObject_RangeReadErrorIs404(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10}, nil
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil || body.Error.Code != models.ErrCodeObjectNotFound {
t.Fatalf("error = %+v, want code %s", body.Error, models.ErrCodeObjectNotFound)
}
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, _, _ int64) (io.ReadCloser, error) {
return nil, errors.New("read failed")
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4", nil)
req.Header.Set("Range", "bytes=0-5")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
// A ranged request with download=true still streams 206 but marks the body as
// an attachment instead of inline.
func TestGetObject_RangeWithDownloadSetsAttachment(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10, ContentType: "video/mp4"}, nil
}
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, _, _ int64) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("01234")), nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4?download=true", nil)
req.Header.Set("Range", "bytes=0-4")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", resp.StatusCode)
}
if got := resp.Header.Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
t.Errorf("Content-Disposition = %q, want attachment", got)
}
}
// GetObject rejects a request that resolves to an empty object key with 400.
// This guards the wildcard dispatch path where the key comes from locals.
func TestGetObject_EmptyKeyIsBadRequest(t *testing.T) {
s3 := &mocks.S3Mock{}
h := NewObjectHandler(s3, &mintStub{})
app := fiber.New()
// Mounted without a :key param so the handler resolves an empty key.
app.Get("/buckets/:bucket/object", h.GetObject)
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/object", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
if body.Error.Message != "The specified key does not exist" {
t.Errorf("message = %q", body.Error.Message)
}
}
-68
View File
@@ -1,68 +0,0 @@
package handlers
import (
"strconv"
"strings"
)
// byteRange is a resolved, inclusive byte range within an object.
type byteRange struct {
start int64
end int64
}
// parseRangeHeader resolves a Range request header against the object size.
// It supports a single "bytes=" range in its three forms: start-end, start-,
// and -suffix. A nil result with unsatisfiable false means serve the full
// object with 200; absent, malformed, and multi-range headers all land there,
// which RFC 9110 permits. unsatisfiable true means respond 416.
func parseRangeHeader(header string, size int64) (rng *byteRange, unsatisfiable bool) {
spec, ok := strings.CutPrefix(header, "bytes=")
if !ok {
return nil, false
}
spec = strings.TrimSpace(spec)
if strings.Contains(spec, ",") {
return nil, false
}
startStr, endStr, ok := strings.Cut(spec, "-")
if !ok {
return nil, false
}
startStr = strings.TrimSpace(startStr)
endStr = strings.TrimSpace(endStr)
// Suffix form "-n" asks for the last n bytes.
if startStr == "" {
n, err := strconv.ParseInt(endStr, 10, 64)
if err != nil {
return nil, false
}
if n <= 0 || size == 0 {
return nil, true
}
if n > size {
n = size
}
return &byteRange{start: size - n, end: size - 1}, false
}
start, err := strconv.ParseInt(startStr, 10, 64)
if err != nil || start < 0 {
return nil, false
}
if start >= size {
return nil, true
}
if endStr == "" {
return &byteRange{start: start, end: size - 1}, false
}
end, err := strconv.ParseInt(endStr, 10, 64)
if err != nil || end < start {
return nil, false
}
if end >= size {
end = size - 1
}
return &byteRange{start: start, end: end}, false
}
-54
View File
@@ -1,54 +0,0 @@
package handlers
import "testing"
func TestParseRangeHeader(t *testing.T) {
cases := []struct {
name string
header string
size int64
wantStart int64
wantEnd int64
wantRange bool
wantUnsatisfy bool
}{
{name: "absent header serves full", header: "", size: 10, wantRange: false},
{name: "simple range", header: "bytes=2-6", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
{name: "open ended", header: "bytes=500-", size: 1000, wantStart: 500, wantEnd: 999, wantRange: true},
{name: "suffix", header: "bytes=-300", size: 1000, wantStart: 700, wantEnd: 999, wantRange: true},
{name: "suffix larger than object clamps to full", header: "bytes=-5000", size: 1000, wantStart: 0, wantEnd: 999, wantRange: true},
{name: "end clamped to size", header: "bytes=0-99999", size: 100, wantStart: 0, wantEnd: 99, wantRange: true},
{name: "single byte", header: "bytes=0-0", size: 10, wantStart: 0, wantEnd: 0, wantRange: true},
{name: "start beyond size is unsatisfiable", header: "bytes=100-", size: 100, wantUnsatisfy: true},
{name: "suffix zero is unsatisfiable", header: "bytes=-0", size: 100, wantUnsatisfy: true},
{name: "any range on empty object is unsatisfiable", header: "bytes=0-", size: 0, wantUnsatisfy: true},
{name: "suffix on empty object is unsatisfiable", header: "bytes=-5", size: 0, wantUnsatisfy: true},
{name: "multi range ignored", header: "bytes=0-1,3-4", size: 10, wantRange: false},
{name: "non byte unit ignored", header: "items=0-5", size: 10, wantRange: false},
{name: "end before start ignored", header: "bytes=6-2", size: 10, wantRange: false},
{name: "garbage start ignored", header: "bytes=abc-5", size: 10, wantRange: false},
{name: "garbage end ignored", header: "bytes=5-abc", size: 10, wantRange: false},
{name: "garbage suffix ignored", header: "bytes=-abc", size: 10, wantRange: false},
{name: "negative start ignored", header: "bytes=-5-8", size: 10, wantRange: false},
{name: "missing dash ignored", header: "bytes=5", size: 10, wantRange: false},
{name: "whitespace tolerated", header: "bytes= 2-6 ", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rng, unsatisfiable := parseRangeHeader(tc.header, tc.size)
if unsatisfiable != tc.wantUnsatisfy {
t.Fatalf("unsatisfiable = %v, want %v", unsatisfiable, tc.wantUnsatisfy)
}
if tc.wantRange {
if rng == nil {
t.Fatalf("rng = nil, want %d-%d", tc.wantStart, tc.wantEnd)
}
if rng.start != tc.wantStart || rng.end != tc.wantEnd {
t.Errorf("rng = %d-%d, want %d-%d", rng.start, rng.end, tc.wantStart, tc.wantEnd)
}
} else if rng != nil {
t.Errorf("rng = %d-%d, want nil", rng.start, rng.end)
}
})
}
}
+7 -18
View File
@@ -3,6 +3,7 @@ package handlers
import (
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -35,9 +36,7 @@ func (h *UserHandler) ListUsers(c fiber.Ctx) error {
keys, err := h.adminService.ListKeys(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert to UserInfo format
@@ -135,9 +134,7 @@ func (h *UserHandler) CreateUser(c fiber.Ctx) error {
// Create the key
keyInfo, err := h.adminService.CreateKey(ctx, createReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
@@ -188,9 +185,7 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
// Delete the key
err := h.adminService.DeleteKey(ctx, accessKey)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(map[string]interface{}{
@@ -223,9 +218,7 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
// Get key information (without secret key)
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
@@ -275,9 +268,7 @@ func (h *UserHandler) GetUserSecretKey(c fiber.Ctx) error {
// Get key information WITH secret key
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, true)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get secret key: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return only the secret key
@@ -347,9 +338,7 @@ func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
// Update the key
keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
+29
View File
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
@@ -365,3 +366,31 @@ func TestUpdateUser_AdminError500(t *testing.T) {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestDeleteUser_UpstreamAccessDenied(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.DeleteKeyFn = func(_ context.Context, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 403,
Code: "AccessDenied",
Message: "permission denied",
Source: "garage",
}
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/abc", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil || body.Error.Code != models.ErrCodeForbidden {
t.Fatalf("error = %+v, want code %s", body.Error, models.ErrCodeForbidden)
}
if body.Error.Message != "permission denied" {
t.Errorf("message = %q", body.Error.Message)
}
}
+11 -101
View File
@@ -1,9 +1,6 @@
package middleware
import (
"net/url"
"strings"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
@@ -20,39 +17,14 @@ import (
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler {
return func(c fiber.Ctx) error {
// If no auth is enabled, allow all requests.
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled && !cfg.Token.Enabled {
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
return c.Next()
}
// Preview tokens authenticate object GETs from media elements, which
// cannot send an Authorization header. The token was minted behind an
// object.read check, names one exact object, and expires on its own.
//
// Bucket and key come from the raw request path (previewRouteParts),
// not from c.Params("bucket")/c.Params("*"). routes.go registers this
// AuthMiddleware twice for the object GET route: once cascaded from
// the /api/v1 group's Use middleware (which runs before Fiber has
// matched the specific wildcard route, so its params are not bound
// yet) and once more directly on the route itself (params bound). The
// group-cascaded pass would otherwise dead-end here on empty params
// and fall through to a 401 before the bound-params pass ever runs.
// Parsing the static path shape gives the same, correct answer in
// both positions without weakening the contract: it only ever
// resolves the exact bucket and key named in the URL.
if pt := c.Query("pt"); pt != "" && c.Method() == fiber.MethodGet {
bucket, _ := previewRouteParts(c)
key := previewObjectKey(c)
if bucket != "" && key != "" && authService.ValidatePreviewToken(pt, bucket, key) == nil {
c.Locals(auth.PreviewTokenLocalsKey, &auth.PreviewClaims{Bucket: bucket, Key: key})
enrichRequestLogger(c, "preview-token", "preview_token")
return c.Next()
}
}
authHeader := c.Get("Authorization")
// Try bearer token auth (works for admin, token, or any JWT session)
if (cfg.Admin.Enabled || cfg.Token.Enabled) && authHeader != "" {
// Try admin auth if enabled and header is present.
if cfg.Admin.Enabled && authHeader != "" {
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
userInfo, err := authService.ValidateSessionToken(token)
@@ -108,76 +80,14 @@ func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) {
}
func authMethodsEnabled(cfg *config.AuthConfig) string {
methods := []string{}
if cfg.Admin.Enabled {
methods = append(methods, "admin")
}
if cfg.OIDC.Enabled {
methods = append(methods, "oidc")
}
if cfg.Token.Enabled {
methods = append(methods, "token")
}
if len(methods) == 0 {
switch {
case cfg.Admin.Enabled && cfg.OIDC.Enabled:
return "admin+oidc"
case cfg.Admin.Enabled:
return "admin"
case cfg.OIDC.Enabled:
return "oidc"
default:
return "none"
}
return strings.Join(methods, "+")
}
// previewRouteParts extracts the bucket and raw (still percent-encoded)
// object key from a request path shaped like
// "/api/v1/buckets/<bucket>/objects/<key>", the only shape the object GET
// route matches. It parses c.Path() directly rather than reading Fiber's
// bound :bucket/* route params, because those params are unset whenever this
// runs ahead of the specific route match (see the AuthMiddleware comment
// above). c.Path() reflects the incoming request path from the start of
// request handling, independent of routing state, so this returns the same
// answer no matter where in the chain it runs. Any path not matching the
// shape returns ("", "").
func previewRouteParts(c fiber.Ctx) (bucket, rawKey string) {
const prefix = "/api/v1/buckets/"
path := c.Path()
if !strings.HasPrefix(path, prefix) {
return "", ""
}
rest := path[len(prefix):]
slash := strings.IndexByte(rest, '/')
if slash < 0 {
return "", ""
}
bucket = rest[:slash]
rest = rest[slash+1:]
const objectsPrefix = "objects/"
if !strings.HasPrefix(rest, objectsPrefix) {
return "", ""
}
return bucket, rest[len(objectsPrefix):]
}
// previewObjectKey decodes the wildcard object key the same way the routes
// layer does. Requests targeting the JSON subroutes return "" because a
// preview token only ever grants the plain byte download.
func previewObjectKey(c fiber.Ctx) string {
_, raw := previewRouteParts(c)
// A raw trailing slash is the one case where c.Path() (used here) and the
// served c.Params("*") diverge: Fiber trims the trailing slash from the
// bound wildcard, so validating against the un-trimmed key could authorize
// a token for "dir/" to serve "dir", or let "x/metadata/" reach the
// /metadata subroute. The SPA always percent-encodes keys as one segment,
// so a legitimate key ending in "/" arrives as "...%2F", never a raw
// trailing slash. Refuse the raw-trailing-slash form so the validated key
// can never diverge from the served key.
if raw == "" || strings.HasSuffix(raw, "/") {
return ""
}
decoded, err := url.QueryUnescape(raw)
if err != nil {
decoded = raw
}
for _, suffix := range []string{"/metadata", "/presign", "/preview-url"} {
if strings.HasSuffix(decoded, suffix) {
return ""
}
}
return decoded
}
-207
View File
@@ -3,13 +3,10 @@ package middleware
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
@@ -404,207 +401,3 @@ func TestAuthMiddleware_Both_AllInvalid_Returns401WithCombinedMethodLabel(t *tes
t.Errorf("auth_method = %v, want admin+oidc", warn["auth_method"])
}
}
// newPreviewTokenApp mirrors the production object GET route shape. Note it
// registers AuthMiddleware via a bare app.Use(), same as routes.go's /api/v1
// group cascade: bucket and key are read from the raw request path
// (previewRouteParts), not from c.Params("bucket")/c.Params("*"), precisely
// because those Fiber route params are not yet bound when a Use()-registered
// middleware executes ahead of the specific :bucket/* route match. routes.go
// registers AuthMiddleware a second time directly on the object route too;
// this test only needs one registration to exercise the same path-parsing
// code the real group cascade hits first.
func newPreviewTokenApp(t *testing.T, authCfg *config.AuthConfig, svc *auth.Service) *fiber.App {
t.Helper()
app := fiber.New()
app.Use(AuthMiddleware(authCfg, svc))
handler := func(c fiber.Ctx) error {
claims, _ := c.Locals(auth.PreviewTokenLocalsKey).(*auth.PreviewClaims)
if claims == nil {
return c.SendString("no-claims")
}
return c.SendString("claims:" + claims.Bucket + "/" + claims.Key)
}
app.Get("/api/v1/buckets/:bucket/objects/*", handler)
app.Delete("/api/v1/buckets/:bucket/objects/*", handler)
return app
}
func previewAuthConfig() *config.AuthConfig {
return &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
}
}
func TestAuthMiddleware_ValidPreviewTokenAllowsObjectGET(t *testing.T) {
authCfg := previewAuthConfig()
svc := newAuthSvc(t, authCfg)
app := newPreviewTokenApp(t, authCfg, svc)
token, _, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
req := httptest.NewRequest("GET", "/api/v1/buckets/b1/objects/dir%2Fclip.mp4?pt="+url.QueryEscape(token), nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "claims:b1/dir/clip.mp4" {
t.Errorf("body = %q, want the preview claims set in locals", body)
}
}
func TestAuthMiddleware_PreviewTokenRejections(t *testing.T) {
authCfg := previewAuthConfig()
svc := newAuthSvc(t, authCfg)
app := newPreviewTokenApp(t, authCfg, svc)
good, _, err := svc.MintPreviewToken("b1", "k.mp4", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
expired, _, err := svc.MintPreviewToken("b1", "k.mp4", -time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
// A token whose claimed key genuinely ends in a slash. On the wire the SPA
// sends this as one percent-encoded segment ("dir%2F"); this case instead
// sends the ambiguous raw form ("dir/"). c.Path() keeps the trailing
// slash, but the served c.Params("*") would be trimmed to "dir", so the
// token would name "dir/" while the handler serves "dir": a different
// object. previewObjectKey refuses the raw-trailing-slash form, so this
// falls through to normal auth and 401s.
trailingDir, _, err := svc.MintPreviewToken("b1", "dir/", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
// A token for a key ending in "/metadata/". Decoded it is "x/metadata/",
// whose HasSuffix "/metadata" is false because of the trailing slash, so
// the subroute guard would not fire; but the served c.Params("*") is
// trimmed to "x/metadata" and routes to the /metadata subroute. The
// raw-trailing-slash refusal blocks this before either divergence matters.
trailingMeta, _, err := svc.MintPreviewToken("b1", "x/metadata/", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
cases := []struct {
name string
method string
path string
}{
{name: "wrong key", method: "GET", path: "/api/v1/buckets/b1/objects/other.mp4?pt=" + url.QueryEscape(good)},
{name: "wrong bucket", method: "GET", path: "/api/v1/buckets/b2/objects/k.mp4?pt=" + url.QueryEscape(good)},
{name: "expired", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4?pt=" + url.QueryEscape(expired)},
{name: "metadata subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fmetadata?pt=" + url.QueryEscape(good)},
{name: "presign subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fpresign?pt=" + url.QueryEscape(good)},
{name: "preview-url subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fpreview-url?pt=" + url.QueryEscape(good)},
{name: "delete method", method: "DELETE", path: "/api/v1/buckets/b1/objects/k.mp4?pt=" + url.QueryEscape(good)},
{name: "garbage token", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4?pt=garbage"},
{name: "raw trailing slash key", method: "GET", path: "/api/v1/buckets/b1/objects/dir/?pt=" + url.QueryEscape(trailingDir)},
{name: "raw trailing slash reaching metadata subroute", method: "GET", path: "/api/v1/buckets/b1/objects/x/metadata/?pt=" + url.QueryEscape(trailingMeta)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, err := app.Test(httptest.NewRequest(tc.method, tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Errorf("status = %d, want 401 fallthrough to normal auth", resp.StatusCode)
}
})
}
}
// TestPreviewRouteParts exercises previewRouteParts directly against every
// branch of its path-shape parsing: no "/api/v1/buckets/" prefix, a bucket
// segment with nothing after it, a bucket segment followed by something
// other than "objects/", and the well formed shape. This parsing runs in
// place of Fiber's :bucket/* param binding (see the comment on
// previewRouteParts), so its edge cases need direct coverage independent of
// AuthMiddleware's own tests.
func TestPreviewRouteParts(t *testing.T) {
app := fiber.New()
var gotBucket, gotKey string
app.Get("/*", func(c fiber.Ctx) error {
gotBucket, gotKey = previewRouteParts(c)
return c.SendString("ok")
})
cases := []struct {
name string
path string
wantBucket string
wantKey string
}{
{name: "no buckets prefix", path: "/other/path", wantBucket: "", wantKey: ""},
{name: "bucket segment with no trailing slash", path: "/api/v1/buckets/mybucket", wantBucket: "", wantKey: ""},
{name: "segment after bucket is not objects", path: "/api/v1/buckets/mybucket/permissions", wantBucket: "", wantKey: ""},
{name: "well formed", path: "/api/v1/buckets/mybucket/objects/dir%2Fclip.mp4", wantBucket: "mybucket", wantKey: "dir%2Fclip.mp4"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotBucket, gotKey = "unset", "unset"
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if gotBucket != tc.wantBucket || gotKey != tc.wantKey {
t.Errorf("previewRouteParts(%q) = (%q, %q), want (%q, %q)", tc.path, gotBucket, gotKey, tc.wantBucket, tc.wantKey)
}
})
}
}
// TestPreviewObjectKey covers previewObjectKey's own branches beyond what
// the AuthMiddleware rejection tests exercise incidentally: a plain key with
// no reserved suffix decodes normally, and a path that previewRouteParts
// can't parse at all yields "".
func TestPreviewObjectKey(t *testing.T) {
app := fiber.New()
var got string
app.Get("/*", func(c fiber.Ctx) error {
got = previewObjectKey(c)
return c.SendString("ok")
})
cases := []struct {
name string
path string
want string
}{
{name: "plain key decodes", path: "/api/v1/buckets/b/objects/dir%2Fclip.mp4", want: "dir/clip.mp4"},
{name: "unparseable route returns empty", path: "/not-a-bucket-route", want: ""},
// A key that genuinely ends in a slash is legitimate when the SPA sends
// it as one encoded segment ("dir%2F"): raw has no literal trailing
// slash, so it is accepted and decodes to "dir/", matching the served
// key. Only the raw-trailing-slash form is refused.
{name: "encoded trailing slash accepted", path: "/api/v1/buckets/b/objects/dir%2F", want: "dir/"},
// The ambiguous raw-trailing-slash form is refused (returns "").
{name: "raw trailing slash refused", path: "/api/v1/buckets/b/objects/dir/", want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got = "unset"
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got != tc.want {
t.Errorf("previewObjectKey(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
+1 -26
View File
@@ -1,9 +1,6 @@
package models
import (
"encoding/json"
"time"
)
import "time"
// GarageKeyInfo represents detailed information about a Garage access key
type GarageKeyInfo struct {
@@ -86,10 +83,6 @@ type GarageBucketInfo struct {
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
Quotas *BucketQuotas `json:"quotas,omitempty"`
// EffectivePermissions is the caller's prefix-scoped permissions on this
// bucket, computed server-side. Omitted when access control is disabled.
EffectivePermissions []string `json:"effective_permissions,omitempty"`
}
// BucketWebsiteConfig represents website configuration for a bucket
@@ -187,24 +180,6 @@ type ClusterHealth struct {
PartitionsAllOk int `json:"partitionsAllOk"`
}
// UnmarshalJSON handles both "storageNodesOk" (Garage v2.0.0) and
// "storageNodesUp" (Garage v2.1.0+, v1.x) field names.
func (h *ClusterHealth) UnmarshalJSON(data []byte) error {
type plain ClusterHealth
var aux struct {
plain
StorageNodesOk int `json:"storageNodesOk"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*h = ClusterHealth(aux.plain)
if h.StorageNodesUp == 0 && aux.StorageNodesOk != 0 {
h.StorageNodesUp = aux.StorageNodesOk
}
return nil
}
// ClusterStatus represents the current status of the cluster
type ClusterStatus struct {
LayoutVersion int `json:"layoutVersion"`
@@ -1,46 +0,0 @@
package models
import (
"encoding/json"
"testing"
)
func TestClusterHealth_StorageNodesOk_BackCompat(t *testing.T) {
raw := `{
"status": "healthy",
"knownNodes": 3,
"connectedNodes": 3,
"storageNodes": 3,
"storageNodesOk": 3,
"partitions": 256,
"partitionsQuorum": 256,
"partitionsAllOk": 256
}`
var h ClusterHealth
if err := json.Unmarshal([]byte(raw), &h); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if h.StorageNodesUp != 3 {
t.Fatalf("expected StorageNodesUp=3, got %d", h.StorageNodesUp)
}
}
func TestClusterHealth_StorageNodesUp(t *testing.T) {
raw := `{
"status": "healthy",
"knownNodes": 3,
"connectedNodes": 3,
"storageNodes": 3,
"storageNodesUp": 3,
"partitions": 256,
"partitionsQuorum": 256,
"partitionsAllOk": 256
}`
var h ClusterHealth
if err := json.Unmarshal([]byte(raw), &h); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if h.StorageNodesUp != 3 {
t.Fatalf("expected StorageNodesUp=3, got %d", h.StorageNodesUp)
}
}
-8
View File
@@ -29,11 +29,3 @@ type UpdateBucketWebsiteRequest struct {
IndexDocument string `json:"indexDocument,omitempty"`
ErrorDocument string `json:"errorDocument,omitempty"`
}
// UpdateBucketQuotasRequest represents a request to update bucket quota settings.
// A nil field means "clear this quota" (unlimited). A non-nil field must be > 0;
// Garage rejects 0.
type UpdateBucketQuotasRequest struct {
MaxSize *int64 `json:"maxSize,omitempty"`
MaxObjects *int64 `json:"maxObjects,omitempty"`
}
+6 -15
View File
@@ -47,11 +47,6 @@ type BucketInfo struct {
Region string `json:"region,omitempty"`
WebsiteAccess bool `json:"websiteAccess"`
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
Quotas *BucketQuotas `json:"quotas,omitempty"`
// EffectivePermissions is the caller's prefix-scoped permissions on this
// bucket, computed server-side. Omitted when access control is disabled.
EffectivePermissions []string `json:"effective_permissions,omitempty"`
}
// BucketListResponse represents a list of buckets
@@ -150,16 +145,10 @@ type PresignedURLResponse struct {
Key string `json:"key"`
}
type PreviewURLResponse struct {
URL string `json:"url"`
ExpiresAt string `json:"expires_at"`
}
type ObjectDeleteMultipleResponse struct {
Bucket string `json:"bucket"`
Deleted int `json:"deleted"`
Keys []string `json:"keys"`
Prefixes []string `json:"prefixes,omitempty"`
Bucket string `json:"bucket"`
Deleted int `json:"deleted"`
Keys []string `json:"keys"`
}
// UserListResponse represents a list of users/keys
@@ -207,5 +196,7 @@ const (
ErrCodeUploadFailed = "UPLOAD_FAILED"
ErrCodeDeleteFailed = "DELETE_FAILED"
ErrCodeListFailed = "LIST_FAILED"
ErrCodeUnsupported = "UNSUPPORTED"
ErrCodeBucketNotEmpty = "BUCKET_NOT_EMPTY"
ErrCodeKeyNotFound = "KEY_NOT_FOUND"
ErrCodeInvalidRequest = "INVALID_REQUEST"
)
+39 -85
View File
@@ -2,7 +2,6 @@ package routes
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
@@ -30,8 +29,6 @@ func SetupRoutes(
userHandler *handlers.UserHandler,
clusterHandler *handlers.ClusterHandler,
monitoringHandler *handlers.MonitoringHandler,
capabilitiesHandler *handlers.CapabilitiesHandler,
az *authz.Middleware,
) {
// Apply CORS middleware globally
app.Use(middleware.CORSMiddleware(&cfg.CORS))
@@ -49,50 +46,34 @@ func SetupRoutes(
// Auth configuration endpoint (always accessible, no auth required)
app.Get("/auth/config", authHandler.GetAuthConfig)
// Public Prometheus metrics endpoint (no auth), opt-in via auth.metrics_public.
// Registered outside /api/v1 so it bypasses the AuthMiddleware/ResolveSubject
// cascade and the VerifyRouteCoverage fail-closed guard entirely; the
// authenticated /api/v1/monitoring/metrics route is unaffected. Because it is
// registered before the SPA fallback below, Fiber matches it first.
// Protect it at the network layer (NetworkPolicy / trusted scrape network).
if cfg.Auth.MetricsPublic {
app.Get("/metrics", monitoringHandler.GetMetrics)
}
// API v1 group
api := app.Group("/api/v1")
// Apply authentication middleware to all API routes
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
// Resolve the authz Subject once per request, right after authentication.
api.Use(az.ResolveSubject())
api.Get("/capabilities", capabilitiesHandler.GetCapabilities)
// Bucket routes
buckets := api.Group("/buckets")
{
buckets.Get("/", az.Require(authz.ScopeNone, authz.PermBucketList), bucketHandler.ListBuckets) // List all buckets
buckets.Post("/", az.Require(authz.BucketFromBody(), authz.PermBucketCreate), bucketHandler.CreateBucket) // Create a new bucket
buckets.Get("/:name", az.Require(authz.BucketFromParam("name"), authz.PermBucketRead), bucketHandler.GetBucketInfo) // Get bucket info
buckets.Delete("/:name", az.Require(authz.BucketFromParam("name"), authz.PermBucketDelete), bucketHandler.DeleteBucket) // Delete a bucket
buckets.Post("/:name/permissions", az.Require(authz.BucketFromParam("name"), authz.PermAllowBucketKey, authz.PermDenyBucketKey), bucketHandler.GrantBucketPermission) // Grant bucket permissions (allow+deny)
buckets.Put("/:name/website", az.Require(authz.BucketFromParam("name"), authz.PermBucketUpdate), bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
buckets.Put("/:name/quotas", az.Require(authz.BucketFromParam("name"), authz.PermBucketUpdate), bucketHandler.UpdateBucketQuotas) // Update bucket quotas
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
}
// Object routes
objects := api.Group("/buckets/:bucket/objects")
{
objects.Get("/", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectList), objectHandler.ListObjects) // List objects in bucket
objects.Post("/", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.UploadObject) // Upload object (multipart)
objects.Post("/upload-multiple", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.UploadMultipleObjects) // Upload multiple objects
objects.Post("/delete-multiple", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectDelete), objectHandler.DeleteMultipleObjects) // Delete multiple objects
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
}
// Directory routes (zero-byte directory markers)
api.Post("/buckets/:bucket/directories", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.CreateDirectory)
api.Post("/buckets/:bucket/directories", objectHandler.CreateDirectory)
// Fiber v3 does not auto-decode wildcard params; fall back to the raw
// value when QueryUnescape fails.
@@ -113,9 +94,6 @@ func SetupRoutes(
case strings.HasSuffix(path, "/presign"):
c.Locals("objectKey", strings.TrimSuffix(path, "/presign"))
return objectHandler.GetPresignedURL(c)
case strings.HasSuffix(path, "/preview-url"):
c.Locals("objectKey", strings.TrimSuffix(path, "/preview-url"))
return objectHandler.GetPreviewURL(c)
default:
c.Locals("objectKey", path)
return objectHandler.GetObject(c)
@@ -132,41 +110,38 @@ func SetupRoutes(
return objectHandler.GetObjectMetadata(c)
}
// Register with auth middleware. Although these routes live on app, not
// api, the api group's .Use() middlewares (AuthMiddleware, ResolveSubject)
// cascade onto them by path prefix, so ResolveSubject is not repeated here
// (TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks that in).
app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectRead), objectWildcardHandler)
app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectDelete), objectDeleteHandler)
app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectRead), objectHeadHandler)
// Register with auth middleware
app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler)
app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler)
app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler)
// User/Key management routes
users := api.Group("/users")
{
users.Get("/", az.Require(authz.ScopeNone, authz.PermKeyList), userHandler.ListUsers) // List all users/keys
users.Post("/", az.Require(authz.ScopeNone, authz.PermKeyCreate), userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyRead), userHandler.GetUser) // Get user info
users.Get("/:access_key/secret", az.Require(authz.ScopeNone, authz.PermKeyReadSecret), userHandler.GetUserSecretKey) // Get user secret key
users.Delete("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyDelete), userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyUpdate), userHandler.UpdateUserPermissions) // Update user permissions
users.Get("/", userHandler.ListUsers) // List all users/keys
users.Post("/", userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", userHandler.GetUser) // Get user info
users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
}
// Cluster management routes
cluster := api.Group("/cluster")
{
cluster.Get("/health", az.Require(authz.ScopeNone, authz.PermClusterHealth), clusterHandler.GetHealth) // Get cluster health
cluster.Get("/status", az.Require(authz.ScopeNone, authz.PermClusterStatus), clusterHandler.GetStatus) // Get cluster status
cluster.Get("/statistics", az.Require(authz.ScopeNone, authz.PermClusterStatistics), clusterHandler.GetStatistics) // Get cluster statistics
cluster.Get("/nodes/:node_id", az.Require(authz.ScopeNone, authz.PermNodeInfo), clusterHandler.GetNodeInfo) // Get node info
cluster.Get("/nodes/:node_id/statistics", az.Require(authz.ScopeNone, authz.PermNodeStatistics), clusterHandler.GetNodeStatistics) // Get node statistics
cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health
cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status
cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics
cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info
cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics
}
// Monitoring routes
monitoring := api.Group("/monitoring")
{
monitoring.Get("/metrics", az.Require(authz.ScopeNone, authz.PermClusterStatistics), monitoringHandler.GetMetrics) // Get Prometheus metrics
monitoring.Get("/admin-health", az.Require(authz.ScopeNone, authz.PermClusterHealth), monitoringHandler.CheckAdminHealth) // Check Admin API health
monitoring.Get("/dashboard", az.Require(authz.ScopeNone, authz.PermClusterStatistics), monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics
monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
}
// Admin auth login endpoint (only if admin is enabled)
@@ -174,13 +149,8 @@ func SetupRoutes(
app.Post("/auth/login", authHandler.LoginAdmin)
}
// Token auth login endpoint (only if token auth is enabled)
if cfg.Auth.Token.Enabled {
app.Post("/auth/login-token", authHandler.LoginToken)
}
// Auth "me" endpoint (if any auth is enabled)
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled || cfg.Auth.Token.Enabled {
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled {
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
}
@@ -254,13 +224,11 @@ func SetupRoutes(
})
}
// With access_control configured, non-admin users may log in:
// they get team-scoped (possibly zero) permissions and
// default-deny protects everything else. Without it, the
// admin role remains the only thing standing between an IdP
// account and full cluster access, so keep the historical gate.
adminRoles := cfg.Auth.OIDC.EffectiveAdminRoles()
if len(adminRoles) > 0 {
// Enforce admin role if configured. Roles are often absent from the
// ID token and the userinfo endpoint (Keycloak emits resource_access
// only in the access token by default), so fall back to the access
// token and then the userinfo endpoint before denying access.
if cfg.Auth.OIDC.AdminRole != "" {
if !authService.IsAdmin(userInfo) {
if roles := authService.ExtractRolesFromAccessToken(token.AccessToken); len(roles) > 0 {
userInfo.Roles = roles
@@ -271,31 +239,18 @@ func SetupRoutes(
userInfo.Roles = ui.Roles
}
}
if cfg.AccessControl == nil && !authService.IsAdmin(userInfo) {
if !authService.IsAdmin(userInfo) {
logger.Warn().
Str("username", userInfo.Username).
Strs("required_roles", adminRoles).
Str("required_role", cfg.Auth.OIDC.AdminRole).
Strs("roles", userInfo.Roles).
Msg("OIDC login denied: user does not have any required admin role")
Msg("OIDC login denied: user does not have required admin role")
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "User does not have the required admin role",
})
}
}
// Teams follow the same claim-location fallbacks as roles.
if cfg.Auth.OIDC.TeamAttributePath != "" && len(userInfo.Teams) == 0 {
if teams := authService.ExtractTeamsFromAccessToken(token.AccessToken); len(teams) > 0 {
userInfo.Teams = teams
}
}
if cfg.Auth.OIDC.TeamAttributePath != "" && len(userInfo.Teams) == 0 {
if ui, err := authService.GetUserInfo(ctx, token); err == nil && len(ui.Teams) > 0 {
userInfo.Teams = ui.Teams
}
}
userInfo.AuthMethod = "oidc"
// Generate JWT session token
sessionToken, err := authService.GenerateSessionToken(userInfo)
if err != nil {
@@ -346,8 +301,7 @@ func SetupRoutes(
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/auth") ||
strings.HasPrefix(path, "/health") ||
strings.HasPrefix(path, "/docs") ||
path == "/metrics" {
strings.HasPrefix(path, "/docs") {
logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback")
return c.Next()
}
@@ -1,302 +0,0 @@
package routes
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
// newTestAppWithAuthz builds a fully-wired fiber.App via SetupRoutes, reusing
// newTestApp's fixture (disabled-policy authz middleware, admin auth enabled
// so every /api/v1 route is reachable), and returns the *fiber.App directly
// for route-table inspection.
func newTestAppWithAuthz(t *testing.T) *fiber.App {
t.Helper()
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
})
return f.App
}
// TestEveryAPIRouteDeclaresPermission is the CI-level fail-closed guarantee:
// a new /api/v1 route without an authz.Require declaration fails this test
// (and would also refuse to boot via the same check in main).
func TestEveryAPIRouteDeclaresPermission(t *testing.T) {
app := newTestAppWithAuthz(t)
if err := authz.VerifyRouteCoverage(app); err != nil {
t.Fatalf("route coverage: %v", err)
}
}
// newEnabledPolicyFixture builds a SetupRoutes app with an ENABLED
// access-control policy (one team, claim "g-t", bucket prefix "allowed-",
// permissions bucket.list + object.list + object.read) and returns the
// fixture plus a Bearer session token for a member of that team. Admin auth
// is enabled so AuthMiddleware accepts the Bearer JWT; the resolver trusts
// the signed AuthMethod claim ("oidc"), so the user resolves through the
// team policy, not as the synthetic admin.
func newEnabledPolicyFixture(t *testing.T) (*routeFixture, string) {
t.Helper()
cfg := &config.Config{
Server: config.ServerConfig{
Port: 8080,
Environment: "test",
},
Auth: config.AuthConfig{
Admin: config.AdminAuthConfig{
Enabled: true,
Username: "admin",
Password: "pw",
},
},
CORS: config.CORSConfig{},
AccessControl: &config.AccessControlConfig{
Teams: []config.TeamConfig{{
Name: "team-t",
ClaimValues: []string{"g-t"},
Bindings: []config.BindingConfig{{
BucketPrefixes: []string{"allowed-"},
Permissions: []string{"bucket.list", "object.list", "object.read"},
}},
}},
},
}
svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
policy, err := authz.CompilePolicy(cfg.AccessControl)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer())
admin := &mocks.AdminMock{}
s3 := &mocks.S3Mock{}
app := fiber.New()
SetupRoutes(
app,
cfg,
svc,
handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3, svc),
handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
az,
)
token, err := svc.GenerateSessionToken(&auth.UserInfo{
Username: "team-user",
Email: "team-user@example.com",
Teams: []string{"g-t"},
AuthMethod: "oidc",
})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}, token
}
// TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks in the Fiber
// behavior the wildcard wiring relies on: the api group's .Use() middlewares
// (AuthMiddleware, ResolveSubject) cascade by path prefix onto the wildcard
// object routes registered directly on app, so those routes do NOT repeat
// ResolveSubject themselves. If the cascade ever stopped covering them, the
// allowed-bucket request below would 403 with reason no_subject instead of
// reaching the handler.
func TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade(t *testing.T) {
f, token := newEnabledPolicyFixture(t)
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil
}
do := func(method, path string) int {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", method, path, err)
}
return resp.StatusCode
}
// In-scope bucket + held permission (object.read) → the request passes
// Require and reaches the handler. This is the discriminating assertion:
// it can only succeed if the group-level ResolveSubject ran for this
// wildcard route (no subject → Require denies everything).
if code := do("GET", "/api/v1/buckets/allowed-data/objects/somekey"); code != 200 {
t.Errorf("GET allowed bucket: status = %d, want 200 (cascaded ResolveSubject + Require allow)", code)
}
// Out-of-scope bucket → default deny.
if code := do("GET", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
t.Errorf("GET denied bucket: status = %d, want 403", code)
}
// DELETE requires object.delete, which the team does not hold, so it is denied
// even on an in-scope bucket.
if code := do("DELETE", "/api/v1/buckets/allowed-data/objects/somekey"); code != 403 {
t.Errorf("DELETE allowed bucket without object.delete: status = %d, want 403", code)
}
// HEAD wildcard is denied on an out-of-scope bucket too.
if code := do("HEAD", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
t.Errorf("HEAD denied bucket: status = %d, want 403", code)
}
}
// TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions is the
// HTTP-level companion to handlers.TestListBuckets_MapsAliasesAndStats: it
// drives GET /api/v1/buckets through the full authz-wired route (not just the
// handler in isolation) for a team-scoped session, with the mocked admin
// service returning buckets both inside and outside the team's "allowed-"
// prefix. Only in-scope buckets should come back, each carrying the caller's
// effective_permissions.
func TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions(t *testing.T) {
f, token := newEnabledPolicyFixture(t)
f.Admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
return []models.ListBucketsResponseItem{
{ID: "id-a", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-a"}},
{ID: "id-b", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-b"}},
{ID: "id-x", Created: time.Unix(0, 0), GlobalAliases: []string{"denied-x"}},
}, nil
}
f.Admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: alias, Objects: 0, Bytes: 0}, nil
}
req := httptest.NewRequest("GET", "/api/v1/buckets", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.BucketListResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Data.Count != 2 {
t.Fatalf("count = %d, want 2 (denied-x filtered out): %+v", body.Data.Count, body.Data.Buckets)
}
seen := map[string]bool{}
for _, b := range body.Data.Buckets {
seen[b.Name] = true
if !strings.HasPrefix(b.Name, "allowed-") {
t.Errorf("bucket %q returned, want only allowed-* buckets", b.Name)
}
if len(b.EffectivePermissions) == 0 {
t.Errorf("bucket %q: effective_permissions missing", b.Name)
}
}
if !seen["allowed-a"] || !seen["allowed-b"] {
t.Errorf("missing expected buckets, got: %+v", body.Data.Buckets)
}
if seen["denied-x"] {
t.Error("denied-x should not be visible to a team without bucket.list on that prefix")
}
}
// TestPreviewTokenGrantsObjectGET exercises the full production chain: group
// cascade AuthMiddleware accepts the token, ResolveSubject finds no user,
// and Require allows via the preview claims instead of a subject.
func TestPreviewTokenGrantsObjectGET(t *testing.T) {
f, _ := newEnabledPolicyFixture(t)
// The full-object body echoes the key the handler was actually asked to
// serve (the decoded c.Params("*")). Asserting the streamed body equals
// the exact key the token was minted for makes any future divergence
// between the validated key and the served key fail loudly here rather
// than hide behind a constant body.
const mintedKey = "media/clip.mp4"
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader(key)), &models.ObjectInfo{Key: key, Size: int64(len(key)), ContentType: "video/mp4", LastModified: time.Now()}, nil
}
f.S3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 5, ContentType: "video/mp4", LastModified: time.Now()}, nil
}
f.S3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("ell")), nil
}
token, _, err := f.Auth.MintPreviewToken("allowed-data", mintedKey, time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
tokenized := "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4?pt=" + url.QueryEscape(token)
// No Authorization header anywhere in this test.
do := func(method, path, rangeHeader string) *http.Response {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", method, path, err)
}
return resp
}
resp := do("GET", tokenized, "")
if resp.StatusCode != 200 {
t.Fatalf("tokenized GET: status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != mintedKey {
t.Errorf("served key = %q, want %q (validated key must equal served key)", body, mintedKey)
}
// Seeking works through the same token.
resp = do("GET", tokenized, "bytes=1-3")
if resp.StatusCode != 206 {
t.Errorf("tokenized ranged GET: status = %d, want 206", resp.StatusCode)
}
// The token never opens the JSON subroutes or other objects.
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4%2Fmetadata?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
t.Errorf("metadata with token: status = %d, want 401", resp.StatusCode)
}
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/other.mp4?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
t.Errorf("other object with token: status = %d, want 401", resp.StatusCode)
}
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4", ""); resp.StatusCode != 401 {
t.Errorf("no token, no auth: status = %d, want 401", resp.StatusCode)
}
}
@@ -1,93 +0,0 @@
package routes
import (
"context"
"io"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
)
// Flag off (default): /metrics is not registered, and the authenticated
// /api/v1/monitoring/metrics route still rejects unauthenticated requests.
func TestRoutes_MetricsPublic_Disabled_NotRegistered(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
// MetricsPublic defaults to false.
})
expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404)
expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401)
}
// Flag on, with admin auth enabled: /metrics serves without credentials, while
// the authenticated /api/v1/monitoring/metrics route still requires auth.
func TestRoutes_MetricsPublic_Enabled_ServesWithoutAuth(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
c.Auth.MetricsPublic = true
})
f.Admin.GetMetricsFn = func(_ context.Context) (string, error) {
return "garage_metric 1", nil
}
resp := expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 200)
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "garage_metric") {
t.Errorf("GET /metrics body = %q, want it to contain the metrics text", string(body))
}
// The /api/v1 route stays gated; the fail-closed guarantee is intact.
expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401)
}
// Route coverage must still pass with the flag on: /metrics is outside /api/v1,
// so VerifyRouteCoverage neither requires a Require handler for it nor errors.
func TestRoutes_MetricsPublic_Enabled_RouteCoverageStillPasses(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
c.Auth.MetricsPublic = true
})
if err := authz.VerifyRouteCoverage(f.App); err != nil {
t.Errorf("VerifyRouteCoverage returned error with metrics_public on: %v", err)
}
}
// With the metrics flag OFF and the SPA frontend present, GET /metrics must
// return 404 (not the SPA index.html), so a misconfigured Prometheus scrape
// fails loudly instead of silently receiving HTML with a 200 status.
func TestRoutes_MetricsPublic_Disabled_WithSPA_Returns404(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// Create ./frontend/dist/index.html so the SPA fallback mounts.
if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "frontend", "dist", "index.html"),
[]byte("<!doctype html><title>spa</title>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
// MetricsPublic defaults to false → no /metrics route registered.
})
// SPA fallback is mounted; /metrics must be excluded from it → 404, not
// index.html with 200.
expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404)
}
@@ -103,30 +103,6 @@ func TestRoutes_ObjectWildcard_GET_PresignSuffixRoutesToPresigned(t *testing.T)
}
}
func TestRoutes_ObjectWildcard_GET_PreviewURLSuffixRoutesToPreviewURL(t *testing.T) {
f := newNoAuthFixture(t)
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/sub/clip.mp4/preview-url", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.PreviewURLResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
// The dispatch trims the /preview-url suffix, so the key becomes sub/clip.mp4,
// percent-encoded whole (slash to %2F) in the returned URL, with a pt token.
if !strings.Contains(body.Data.URL, "/api/v1/buckets/b1/objects/sub%2Fclip.mp4?pt=") {
t.Errorf("url = %q, want the whole-encoded key with a pt token", body.Data.URL)
}
}
func TestRoutes_ObjectWildcard_DELETE_RoutesToDeleteObject(t *testing.T) {
f := newNoAuthFixture(t)
@@ -1,103 +0,0 @@
package routes
import (
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/config"
)
// newOIDCTeamFixture builds an OIDC-enabled fixture with team_attribute_path
// set and no admin-role gate, so a non-admin user can complete the callback
// and have their teams resolved.
func newOIDCTeamFixture(t *testing.T, teamPath string) (*routeFixture, *testIssuer) {
t.Helper()
iss := newTestIssuer(t)
f := newTestApp(t, func(c *config.Config) {
c.Server.RootURL = "https://app.example"
c.Auth.OIDC = config.OIDCConfig{
Enabled: true,
ClientID: iss.ClientID,
ClientSecret: "secret",
IssuerURL: iss.Server.URL,
Scopes: []string{"openid", "profile", "email"},
AdminRole: "", // no role gate: non-admin OIDC users may log in
UsernameAttribute: "preferred_username",
EmailAttribute: "email",
NameAttribute: "name",
RoleAttributePath: "resource_access.test-client.roles",
TeamAttributePath: teamPath,
CookieName: "session",
CookieHTTPOnly: true,
CookieSameSite: "Lax",
SessionMaxAge: 3600,
}
})
return f, iss
}
// runCallback drives the OIDC callback happy path and returns the resolved
// session's user info (decoded from the session cookie).
func runCallbackTeams(t *testing.T, f *routeFixture) []string {
t.Helper()
state := oidcState(t, f)
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("callback: %v", err)
}
if resp.StatusCode != 303 {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
var sess *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "session" && c.Value != "" {
sess = c
}
}
if sess == nil {
t.Fatal("no session cookie in callback response")
}
info, err := f.Auth.ValidateSessionToken(sess.Value)
if err != nil {
t.Fatalf("ValidateSessionToken: %v", err)
}
return info.Teams
}
func TestRoutes_OIDCCallback_TeamsFromIDToken(t *testing.T) {
f, iss := newOIDCTeamFixture(t, "groups")
// The verified ID token carries the team claim directly.
iss.DefaultIDClaims["groups"] = []any{"garage-team-backend"}
teams := runCallbackTeams(t, f)
if len(teams) != 1 || teams[0] != "garage-team-backend" {
t.Errorf("Teams = %v, want [garage-team-backend] from the ID token", teams)
}
}
func TestRoutes_OIDCCallback_TeamsFromAccessTokenFallback(t *testing.T) {
f, iss := newOIDCTeamFixture(t, "groups")
// ID token carries no team claim; the access token does.
iss.DefaultAccessClaims["groups"] = []any{"garage-team-data"}
teams := runCallbackTeams(t, f)
if len(teams) != 1 || teams[0] != "garage-team-data" {
t.Errorf("Teams = %v, want [garage-team-data] from the access-token fallback", teams)
}
}
func TestRoutes_OIDCCallback_TeamsFromUserInfoFallback(t *testing.T) {
f, iss := newOIDCTeamFixture(t, "groups")
// Neither token carries the team claim; only the userinfo endpoint does.
iss.UserInfoFn = func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sub":"user-1","preferred_username":"alice","email":"alice@example.com","groups":["garage-team-ops"]}`))
}
teams := runCallbackTeams(t, f)
if len(teams) != 1 || teams[0] != "garage-team-ops" {
t.Errorf("Teams = %v, want [garage-team-ops] from the userinfo fallback", teams)
}
}
+1 -12
View File
@@ -12,10 +12,8 @@ import (
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
@@ -58,12 +56,6 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
admin := &mocks.AdminMock{}
s3 := &mocks.S3Mock{}
policy, err := authz.CompilePolicy(nil)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer())
app := fiber.New()
SetupRoutes(
app,
@@ -71,12 +63,10 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
svc,
handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3, svc),
handlers.NewObjectHandler(s3),
handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
az,
)
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}
@@ -206,7 +196,6 @@ func TestRoutes_AllAPIRoutesRegistered(t *testing.T) {
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/metadata"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/presign"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/preview-url"},
{"DELETE", "/api/v1/buckets/b1/objects/folder/file.txt"},
{"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"},
// Users
@@ -1,6 +1,7 @@
package services
import (
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
@@ -15,22 +16,22 @@ import (
"github.com/Noooste/azuretls-client"
)
// GarageV2AdminService handles interactions with the Garage Admin API
type GarageV2AdminService struct {
// GarageAdminService handles interactions with the Garage Admin API
type GarageAdminService struct {
baseURL string
token string
httpClient *azuretls.Session
}
// NewGarageV2AdminService creates a new Garage Admin API service
func NewGarageV2AdminService(cfg *config.GarageConfig, logLevel string) *GarageV2AdminService {
// NewGarageAdminService creates a new Garage Admin API service
func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdminService {
session := azuretls.NewSession()
if logLevel == "debug" {
session.Log()
}
return &GarageV2AdminService{
return &GarageAdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
@@ -38,7 +39,7 @@ func NewGarageV2AdminService(cfg *config.GarageConfig, logLevel string) *GarageV
}
// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors
func (s *GarageV2AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
var resp *azuretls.Response
retryConfig := utils.DefaultRetryConfig()
@@ -63,13 +64,14 @@ func (s *GarageV2AdminService) doRequest(ctx context.Context, method, path strin
return resp, nil
}
// decodeResponse decodes a JSON response into the target structure
// decodeResponse decodes a JSON response into the target structure. Non-2xx
// responses are converted to *apierr.UpstreamError (Source="garage") so the
// handler layer can map them to the correct API error code and HTTP status.
func decodeResponse(resp *azuretls.Response, target interface{}) error {
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
if err := apierr.ParseGarage(resp); err != nil {
return err
}
if target != nil {
@@ -77,12 +79,11 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
return fmt.Errorf("failed to decode response: %w", err)
}
}
return nil
}
// ListKeys returns all access keys in the cluster
func (s *GarageV2AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -90,14 +91,14 @@ func (s *GarageV2AdminService) ListKeys(ctx context.Context) ([]models.ListKeysR
var result []models.ListKeysResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return result, nil
}
// CreateKey creates a new API access key
func (s *GarageV2AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -105,14 +106,14 @@ func (s *GarageV2AdminService) CreateKey(ctx context.Context, req models.CreateK
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetKeyInfo returns information about a specific access key
func (s *GarageV2AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
if showSecret {
path += "&showSecretKey=true"
@@ -125,14 +126,14 @@ func (s *GarageV2AdminService) GetKeyInfo(ctx context.Context, keyID string, sho
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// UpdateKey updates information about an access key
func (s *GarageV2AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
@@ -142,14 +143,14 @@ func (s *GarageV2AdminService) UpdateKey(ctx context.Context, keyID string, req
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// DeleteKey deletes an access key from the cluster
func (s *GarageV2AdminService) DeleteKey(ctx context.Context, keyID string) error {
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
@@ -158,14 +159,14 @@ func (s *GarageV2AdminService) DeleteKey(ctx context.Context, keyID string) erro
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
return err
}
return nil
}
// ImportKey imports an existing API access key
func (s *GarageV2AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -173,14 +174,14 @@ func (s *GarageV2AdminService) ImportKey(ctx context.Context, req models.ImportK
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// ListBuckets returns all buckets in the cluster.
func (s *GarageV2AdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "list_buckets").
@@ -204,7 +205,7 @@ func (s *GarageV2AdminService) ListBuckets(ctx context.Context) ([]models.ListBu
Float64("duration_ms", msSince(start)).
Str("outcome", "failure").
Msg("garage list_buckets decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Debug().
@@ -216,7 +217,7 @@ func (s *GarageV2AdminService) ListBuckets(ctx context.Context) ([]models.ListBu
}
// GetBucketInfo returns detailed information about a bucket by ID.
func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "get_bucket_info").
@@ -235,7 +236,7 @@ func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID strin
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("got bucket info")
@@ -243,7 +244,7 @@ func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID strin
}
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias.
func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "get_bucket_info_by_alias").
@@ -262,7 +263,7 @@ func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalA
var result models.GarageBucketInfo
if err = decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info_by_alias decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("got bucket info by alias")
@@ -270,7 +271,7 @@ func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalA
}
// CreateBucket creates a new bucket via the Admin API.
func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
var alias string
if req.GlobalAlias != nil {
alias = *req.GlobalAlias
@@ -293,7 +294,7 @@ func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.Crea
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("bucket created")
@@ -301,7 +302,7 @@ func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.Crea
}
// UpdateBucket updates bucket settings.
func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "update_bucket").
@@ -320,7 +321,7 @@ func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket updated")
@@ -328,7 +329,7 @@ func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string
}
// DeleteBucket deletes a bucket.
func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string) error {
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "delete_bucket").
@@ -346,7 +347,7 @@ func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string
if err := decodeResponse(resp, nil); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket decode failed")
return fmt.Errorf("failed to process response: %w", err)
return err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket deleted")
@@ -354,7 +355,7 @@ func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string
}
// AddBucketAlias adds an alias to a bucket
func (s *GarageV2AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -362,14 +363,14 @@ func (s *GarageV2AdminService) AddBucketAlias(ctx context.Context, req models.Ad
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// RemoveBucketAlias removes an alias from a bucket
func (s *GarageV2AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -377,14 +378,14 @@ func (s *GarageV2AdminService) RemoveBucketAlias(ctx context.Context, req models
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// AllowBucketKey grants permissions for a key on a bucket.
func (s *GarageV2AdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "allow_bucket_key").
@@ -407,7 +408,7 @@ func (s *GarageV2AdminService) AllowBucketKey(ctx context.Context, req models.Bu
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket key permissions granted")
@@ -415,7 +416,7 @@ func (s *GarageV2AdminService) AllowBucketKey(ctx context.Context, req models.Bu
}
// DenyBucketKey revokes permissions for a key on a bucket
func (s *GarageV2AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -423,14 +424,14 @@ func (s *GarageV2AdminService) DenyBucketKey(ctx context.Context, req models.Buc
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetClusterHealth returns the health status of the cluster
func (s *GarageV2AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -438,14 +439,14 @@ func (s *GarageV2AdminService) GetClusterHealth(ctx context.Context) (*models.Cl
var result models.ClusterHealth
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetClusterStatus returns the current status of the cluster
func (s *GarageV2AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -453,14 +454,14 @@ func (s *GarageV2AdminService) GetClusterStatus(ctx context.Context) (*models.Cl
var result models.ClusterStatus
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetClusterStatistics returns global cluster statistics
func (s *GarageV2AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -468,14 +469,14 @@ func (s *GarageV2AdminService) GetClusterStatistics(ctx context.Context) (*model
var result models.ClusterStatistics
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetNodeInfo returns information about a specific node
func (s *GarageV2AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
@@ -485,14 +486,14 @@ func (s *GarageV2AdminService) GetNodeInfo(ctx context.Context, nodeID string) (
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// GetNodeStatistics returns statistics for a specific node
func (s *GarageV2AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
@@ -502,21 +503,21 @@ func (s *GarageV2AdminService) GetNodeStatistics(ctx context.Context, nodeID str
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// HealthCheck checks if the Admin API is reachable
func (s *GarageV2AdminService) HealthCheck(ctx context.Context) error {
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("health check returned error: %w", err)
return err
}
return nil
@@ -528,7 +529,7 @@ func msSince(t time.Time) float64 {
}
// GetMetrics returns Prometheus metrics from the Admin API
func (s *GarageV2AdminService) GetMetrics(ctx context.Context) (string, error) {
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
@@ -16,9 +16,9 @@ import (
"github.com/rs/zerolog"
)
// newAdminWithServer creates a GarageV2AdminService pointed at a test server
// newAdminWithServer creates a GarageAdminService pointed at a test server
// and returns it with a cleanup hook.
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageV2AdminService {
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminService {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
@@ -26,7 +26,7 @@ func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageV2AdminSe
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}
return NewGarageV2AdminService(cfg, "info")
return NewGarageAdminService(cfg, "info")
}
// ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx.
-132
View File
@@ -1,132 +0,0 @@
package services
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/pkg/logger"
"github.com/Noooste/azuretls-client"
)
var ErrUnsupported = errors.New("operation not supported by this Garage version")
type Capabilities struct {
ClusterStatistics bool `json:"clusterStatistics"`
NodeInfo bool `json:"nodeInfo"`
NodeStatistics bool `json:"nodeStatistics"`
}
func CapabilitiesV2() Capabilities {
return Capabilities{
ClusterStatistics: true,
NodeInfo: true,
NodeStatistics: true,
}
}
func CapabilitiesV1() Capabilities {
return Capabilities{}
}
type AdminServiceResult struct {
Service AdminService
Capabilities Capabilities
APIVersion string
}
// errProbeNotFound means the probed route returned 404. Garage v2.x also serves
// /v1/health, so a 404 on /v2 is the only reliable "this is a v1 server" signal.
var errProbeNotFound = errors.New("probe endpoint not found")
func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceResult, error) {
// retry so a startup fails doesn't lock us to a v1 client.
err := probeEndpointWithRetry(cfg, "/v2/GetClusterHealth")
if err == nil {
logger.Info().Str("api_version", "v2").Msg("Detected Garage admin API v2")
svc := NewGarageV2AdminService(cfg, logLevel)
return &AdminServiceResult{
Service: svc,
Capabilities: CapabilitiesV2(),
APIVersion: "v2",
}, nil
}
// only fall back to v1 on a real 404
// other errors mean the server is up but the probe failed transiently; picking v1 against
// a v2.x server breaks /v1/status with "v1/ endpoint is no longer supported" (issue #78).
if !errors.Is(err, errProbeNotFound) {
return nil, fmt.Errorf(
"could not detect Garage admin API version at %s: %w. Ensure Garage v1.1+ is running and the admin API is reachable",
cfg.AdminEndpoint, err,
)
}
if err := probeEndpointWithRetry(cfg, "/v1/health"); err == nil {
logger.Info().
Str("api_version", "v1").
Msg("Detected Garage admin API v1 — cluster statistics and per-node details will be unavailable")
svc := NewGarageV1AdminService(cfg, logLevel)
return &AdminServiceResult{
Service: svc,
Capabilities: CapabilitiesV1(),
APIVersion: "v1",
}, nil
}
return nil, fmt.Errorf(
"could not connect to Garage admin API at %s. Ensure Garage v1.1+ is running and the admin API is enabled",
cfg.AdminEndpoint,
)
}
const probeAttempts = 4
// probeEndpointWithRetry retries transient failures with backoff, but returns a
// 404 immediately, a missing route won't appear on a retry.
func probeEndpointWithRetry(cfg *config.GarageConfig, path string) error {
var err error
backoff := 250 * time.Millisecond
for attempt := range probeAttempts {
err = probeEndpoint(cfg, path)
if err == nil || errors.Is(err, errProbeNotFound) {
return err
}
if attempt < probeAttempts-1 {
time.Sleep(backoff)
backoff *= 2
}
}
return err
}
func probeEndpoint(cfg *config.GarageConfig, path string) error {
session := azuretls.NewSession()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := session.Do(&azuretls.Request{
Method: http.MethodGet,
Url: cfg.AdminEndpoint + path,
IgnoreBody: true,
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", cfg.AdminToken)},
},
}, ctx)
if err != nil {
return err
}
defer resp.RawBody.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("probe %s returned status 404: %w", path, errProbeNotFound)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("probe %s returned status %d", path, resp.StatusCode)
}
return nil
}
@@ -1,149 +0,0 @@
package services
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/config"
)
func TestErrUnsupportedIsSentinel(t *testing.T) {
err := ErrUnsupported
if !errors.Is(err, ErrUnsupported) {
t.Fatal("ErrUnsupported should match itself via errors.Is")
}
}
func TestCapabilitiesV2AllTrue(t *testing.T) {
caps := CapabilitiesV2()
if !caps.ClusterStatistics || !caps.NodeInfo || !caps.NodeStatistics {
t.Fatalf("v2 capabilities should all be true, got %+v", caps)
}
}
func TestCapabilitiesV1AllFalse(t *testing.T) {
caps := CapabilitiesV1()
if caps.ClusterStatistics || caps.NodeInfo || caps.NodeStatistics {
t.Fatalf("v1 capabilities should all be false, got %+v", caps)
}
}
func TestDetectVersion_V2(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/GetClusterHealth" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
return
}
w.WriteHeader(404)
}))
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
result, err := NewAdminService(cfg, "")
if err != nil {
t.Fatal(err)
}
if result.APIVersion != "v2" {
t.Fatalf("expected v2, got %s", result.APIVersion)
}
if !result.Capabilities.ClusterStatistics {
t.Fatal("v2 should have ClusterStatistics capability")
}
}
func TestDetectVersion_V1(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/GetClusterHealth" {
w.WriteHeader(404)
return
}
if r.URL.Path == "/v1/health" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
return
}
w.WriteHeader(404)
}))
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
result, err := NewAdminService(cfg, "")
if err != nil {
t.Fatal(err)
}
if result.APIVersion != "v1" {
t.Fatalf("expected v1, got %s", result.APIVersion)
}
if result.Capabilities.ClusterStatistics {
t.Fatal("v1 should not have ClusterStatistics capability")
}
}
func TestDetectVersion_Unreachable(t *testing.T) {
cfg := &config.GarageConfig{AdminEndpoint: "http://127.0.0.1:1", AdminToken: "tok"}
_, err := NewAdminService(cfg, "")
if err == nil {
t.Fatal("expected error for unreachable server")
}
}
// Garage v2.x serves /v1/health too, so a transient failure of the /v2 probe
// must not cause a permanent downgrade to the (broken on v2.x) v1 client.
// Regression test for https://github.com/Noooste/garage-ui/issues/78
func TestDetectVersion_V2_TransientProbeFailure(t *testing.T) {
var v2Hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/GetClusterHealth":
v2Hits++
if v2Hits < 3 { // fail the first two attempts, then recover
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
case "/v1/health": // v2.x still answers this
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
result, err := NewAdminService(cfg, "")
if err != nil {
t.Fatal(err)
}
if result.APIVersion != "v2" {
t.Fatalf("expected v2 after transient probe failure, got %s", result.APIVersion)
}
}
// A server that answers /v1/health but returns a server error (not 404) for
// /v2/GetClusterHealth must NOT be detected as v1, because v2.x servers also
// answer /v1/health. Falling through to v1 here is the issue #78 misdetection.
func TestDetectVersion_DoesNotDowngradeOnV2ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/GetClusterHealth":
w.WriteHeader(http.StatusServiceUnavailable)
case "/v1/health":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
result, err := NewAdminService(cfg, "")
if err == nil && result.APIVersion == "v1" {
t.Fatal("must not downgrade to v1 when /v2 returns a server error; v2.x also serves /v1/health")
}
}
@@ -3,6 +3,7 @@ package services
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
@@ -11,18 +12,19 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
)
// newAdminTestServer wires an httptest.Server (with the supplied handler) to a
// fresh *GarageV2AdminService configured with a known bearer token.
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageV2AdminService, *httptest.Server) {
// fresh *GarageAdminService configured with a known bearer token.
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageAdminService, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
svc := NewGarageV2AdminService(&config.GarageConfig{
svc := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token-xyz",
}, "")
@@ -103,8 +105,12 @@ func TestHealthCheck_Non2xxReturnsError(t *testing.T) {
if err == nil {
t.Fatal("expected error for 503, got nil")
}
if !strings.Contains(err.Error(), "503") {
t.Errorf("error should mention status code, got %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("expected *apierr.UpstreamError, got %T (%v)", err, err)
}
if ue.HTTPStatus != http.StatusServiceUnavailable {
t.Errorf("HTTPStatus = %d, want %d", ue.HTTPStatus, http.StatusServiceUnavailable)
}
}
@@ -326,12 +332,12 @@ func TestDeleteBucket_PostWithIDQuery(t *testing.T) {
func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
cases := []struct {
name string
fn func(s *GarageV2AdminService) error
fn func(s *GarageAdminService) error
path string
}{
{
name: "AddBucketAlias",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{})
return err
},
@@ -339,7 +345,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
},
{
name: "RemoveBucketAlias",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{})
return err
},
@@ -347,7 +353,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
},
{
name: "AllowBucketKey",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{})
return err
},
@@ -355,7 +361,7 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
},
{
name: "DenyBucketKey",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{})
return err
},
@@ -379,12 +385,12 @@ func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
func TestClusterEndpoints(t *testing.T) {
cases := []struct {
name string
fn func(s *GarageV2AdminService) error
fn func(s *GarageAdminService) error
path string
}{
{
name: "GetClusterHealth",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.GetClusterHealth(context.Background())
return err
},
@@ -392,7 +398,7 @@ func TestClusterEndpoints(t *testing.T) {
},
{
name: "GetClusterStatus",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.GetClusterStatus(context.Background())
return err
},
@@ -400,7 +406,7 @@ func TestClusterEndpoints(t *testing.T) {
},
{
name: "GetClusterStatistics",
fn: func(s *GarageV2AdminService) error {
fn: func(s *GarageAdminService) error {
_, err := s.GetClusterStatistics(context.Background())
return err
},
@@ -427,12 +433,12 @@ func TestClusterEndpoints(t *testing.T) {
func TestNodeEndpoints_NodeIDInQuery(t *testing.T) {
cases := []struct {
name string
fn func(s *GarageV2AdminService, id string) error
fn func(s *GarageAdminService, id string) error
path string
}{
{
name: "GetNodeInfo",
fn: func(s *GarageV2AdminService, id string) error {
fn: func(s *GarageAdminService, id string) error {
_, err := s.GetNodeInfo(context.Background(), id)
return err
},
@@ -440,7 +446,7 @@ func TestNodeEndpoints_NodeIDInQuery(t *testing.T) {
},
{
name: "GetNodeStatistics",
fn: func(s *GarageV2AdminService, id string) error {
fn: func(s *GarageAdminService, id string) error {
_, err := s.GetNodeStatistics(context.Background(), id)
return err
},
@@ -509,8 +515,15 @@ func TestDoRequest_Non2xxBodyEchoedInError(t *testing.T) {
if err == nil {
t.Fatal("expected error for 400, got nil")
}
if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") {
t.Errorf("error %v should contain 400 and the response body", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("expected *apierr.UpstreamError, got %T (%v)", err, err)
}
if ue.HTTPStatus != http.StatusBadRequest {
t.Errorf("HTTPStatus = %d, want %d", ue.HTTPStatus, http.StatusBadRequest)
}
if !strings.Contains(ue.Message, "bad request") {
t.Errorf("Message = %q, want it to contain the response body", ue.Message)
}
}
@@ -576,10 +589,10 @@ func TestAllMethods_Non2xxReturnsError(t *testing.T) {
}
}
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageV2AdminService
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageAdminService
// branch that enables azuretls' session logging when logLevel == "debug".
func TestDebugLogLevelEnablesSessionLog(t *testing.T) {
svc := NewGarageV2AdminService(&config.GarageConfig{
svc := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: "http://127.0.0.1:1",
AdminToken: "t",
}, "debug")
@@ -598,7 +611,7 @@ func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) {
t.Fatalf("close listener: %v", err)
}
svc := NewGarageV2AdminService(&config.GarageConfig{
svc := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: "http://" + addr,
AdminToken: "irrelevant",
}, "")
-375
View File
@@ -1,375 +0,0 @@
package services
import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
logpkg "Noooste/garage-ui/pkg/logger"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/Noooste/azuretls-client"
)
type GarageV1AdminService struct {
baseURL string
token string
httpClient *azuretls.Session
}
func NewGarageV1AdminService(cfg *config.GarageConfig, logLevel string) *GarageV1AdminService {
session := azuretls.NewSession()
if logLevel == "debug" {
session.Log()
}
return &GarageV1AdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
}
}
func (s *GarageV1AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
var resp *azuretls.Response
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var reqErr error
resp, reqErr = s.httpClient.Do(&azuretls.Request{
Method: method,
Url: s.baseURL + path,
Body: body,
IgnoreBody: true,
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
return reqErr
})
if err != nil {
return nil, err
}
return resp, nil
}
func (s *GarageV1AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/key?list=true", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListKeysResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
func (s *GarageV1AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/key?list", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v1/key?id=%s", keyID)
if showSecret {
path += "&showSecretKey=true"
}
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v1/key?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) DeleteKey(ctx context.Context, keyID string) error {
path := fmt.Sprintf("/v1/key?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodDelete, path, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
func (s *GarageV1AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/key/import", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
log := logpkg.FromCtx(ctx).With().Str("component", "admin-v1").Str("operation", "list_buckets").Logger()
log.Debug().Msg("listing buckets")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/bucket?list", nil)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Msg("list_buckets request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListBucketsResponseItem
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Msg("list_buckets decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
}
log.Debug().Float64("duration_ms", msSince(start)).Int("count", len(result)).Msg("listed buckets")
return result, nil
}
func (s *GarageV1AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v1/bucket?id=%s", bucketID), nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v1/bucket?globalAlias=%s", globalAlias), nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPut, fmt.Sprintf("/v1/bucket?id=%s", bucketID), req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) DeleteBucket(ctx context.Context, bucketID string) error {
resp, err := s.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/v1/bucket?id=%s", bucketID), nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
func (s *GarageV1AdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket/allow", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket/deny", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
var path string
if req.GlobalAlias != nil {
path = fmt.Sprintf("/v1/bucket/alias/global?id=%s&alias=%s", req.BucketID, *req.GlobalAlias)
} else if req.LocalAlias != nil && req.AccessKeyID != nil {
path = fmt.Sprintf("/v1/bucket/alias/local?id=%s&accessKeyId=%s&alias=%s", req.BucketID, *req.AccessKeyID, *req.LocalAlias)
} else {
return nil, fmt.Errorf("AddBucketAlias requires either globalAlias or localAlias+accessKeyId")
}
resp, err := s.doRequest(ctx, http.MethodPut, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
var path string
if req.GlobalAlias != nil {
path = fmt.Sprintf("/v1/bucket/alias/global?id=%s&alias=%s", req.BucketID, *req.GlobalAlias)
} else if req.LocalAlias != nil && req.AccessKeyID != nil {
path = fmt.Sprintf("/v1/bucket/alias/local?id=%s&accessKeyId=%s&alias=%s", req.BucketID, *req.AccessKeyID, *req.LocalAlias)
} else {
return nil, fmt.Errorf("RemoveBucketAlias requires either globalAlias or localAlias+accessKeyId")
}
resp, err := s.doRequest(ctx, http.MethodDelete, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/health", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterHealth
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
type v1StatusResponse struct {
Node string `json:"node"`
GarageVersion string `json:"garageVersion"`
KnownNodes []v1KnownNode `json:"knownNodes"`
Layout *v1Layout `json:"layout"`
}
type v1KnownNode struct {
ID string `json:"id"`
Addr string `json:"addr"`
IsUp bool `json:"isUp"`
LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo"`
Hostname string `json:"hostname"`
}
type v1Layout struct {
Version int `json:"version"`
}
func (s *GarageV1AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/status", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var raw v1StatusResponse
if err := decodeResponse(resp, &raw); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
nodes := make([]models.NodeInfo, len(raw.KnownNodes))
for i, n := range raw.KnownNodes {
addr := n.Addr
hostname := n.Hostname
nodes[i] = models.NodeInfo{
ID: n.ID,
IsUp: n.IsUp,
LastSeenSecsAgo: n.LastSeenSecsAgo,
Hostname: &hostname,
Addr: &addr,
}
}
layoutVersion := 0
if raw.Layout != nil {
layoutVersion = raw.Layout.Version
}
return &models.ClusterStatus{
LayoutVersion: layoutVersion,
Nodes: nodes,
}, nil
}
func (s *GarageV1AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
return nil, ErrUnsupported
}
func (s *GarageV1AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
return nil, ErrUnsupported
}
func (s *GarageV1AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
return nil, ErrUnsupported
}
func (s *GarageV1AdminService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("health check returned error: %w", err)
}
return nil
}
func (s *GarageV1AdminService) GetMetrics(ctx context.Context) (string, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
bodyBytes, err := io.ReadAll(resp.RawBody)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(bodyBytes), nil
}
-635
View File
@@ -1,635 +0,0 @@
package services
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
)
func newV1TestServer(t *testing.T, handler http.Handler) *GarageV1AdminService {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return NewGarageV1AdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}, "")
}
func TestV1_ListKeys(t *testing.T) {
items := []models.ListKeysResponseItem{{ID: "GK1", Name: "key1"}}
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/key" || r.URL.Query().Get("list") == "" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}))
result, err := svc.ListKeys(context.Background())
if err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].ID != "GK1" {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestV1_GetClusterHealth(t *testing.T) {
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/health" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy","knownNodes":3,"connectedNodes":3,"storageNodes":3,"storageNodesOk":3,"partitions":256,"partitionsQuorum":256,"partitionsAllOk":256}`))
}))
health, err := svc.GetClusterHealth(context.Background())
if err != nil {
t.Fatal(err)
}
if health.StorageNodesUp != 3 {
t.Fatalf("expected StorageNodesUp=3, got %d", health.StorageNodesUp)
}
}
func TestV1_GetClusterStatistics_Unsupported(t *testing.T) {
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not make any HTTP request for unsupported operations")
}))
_, err := svc.GetClusterStatistics(context.Background())
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("expected ErrUnsupported, got %v", err)
}
}
func TestV1_GetNodeInfo_Unsupported(t *testing.T) {
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not make any HTTP request for unsupported operations")
}))
_, err := svc.GetNodeInfo(context.Background(), "abc")
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("expected ErrUnsupported, got %v", err)
}
}
func TestV1_GetNodeStatistics_Unsupported(t *testing.T) {
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not make any HTTP request for unsupported operations")
}))
_, err := svc.GetNodeStatistics(context.Background(), "abc")
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("expected ErrUnsupported, got %v", err)
}
}
// v1RecordingHandler returns a handler that records the request and responds with JSON.
func v1RecordingHandler(t *testing.T, status int, body any) (http.Handler, *recordedRequest) {
t.Helper()
rec := &recordedRequest{}
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.method = r.Method
rec.path = r.URL.Path
rec.rawURL = r.URL.RequestURI()
rec.auth = r.Header.Get("Authorization")
if r.Body != nil {
b, _ := io.ReadAll(r.Body)
rec.body = b
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
json.NewEncoder(w).Encode(body)
}
})
return h, rec
}
func newV1RecordingServer(t *testing.T, status int, body any) (*GarageV1AdminService, *recordedRequest) {
t.Helper()
h, rec := v1RecordingHandler(t, status, body)
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
svc := NewGarageV1AdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}, "")
return svc, rec
}
func TestV1_CreateKey(t *testing.T) {
name := "mykey"
want := &models.GarageKeyInfo{AccessKeyID: "GK1", Name: name}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.CreateKey(context.Background(), models.CreateKeyRequest{Name: &name})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/key" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if got.AccessKeyID != "GK1" {
t.Errorf("AccessKeyID = %q, want GK1", got.AccessKeyID)
}
}
func TestV1_GetKeyInfo(t *testing.T) {
want := &models.GarageKeyInfo{AccessKeyID: "ABC"}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.GetKeyInfo(context.Background(), "ABC", true)
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodGet {
t.Errorf("method = %q, want GET", rec.method)
}
if !strings.Contains(rec.rawURL, "id=ABC") || !strings.Contains(rec.rawURL, "showSecretKey=true") {
t.Errorf("rawURL = %q, want id=ABC&showSecretKey=true", rec.rawURL)
}
if got.AccessKeyID != "ABC" {
t.Errorf("got %q, want ABC", got.AccessKeyID)
}
}
func TestV1_UpdateKey(t *testing.T) {
want := &models.GarageKeyInfo{AccessKeyID: "K1"}
svc, rec := newV1RecordingServer(t, 200, want)
_, err := svc.UpdateKey(context.Background(), "K1", models.UpdateKeyRequest{})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost {
t.Errorf("method = %q, want POST", rec.method)
}
if !strings.Contains(rec.rawURL, "id=K1") {
t.Errorf("rawURL = %q, want id=K1", rec.rawURL)
}
}
func TestV1_DeleteKey(t *testing.T) {
svc, rec := newV1RecordingServer(t, 200, nil)
err := svc.DeleteKey(context.Background(), "K1")
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodDelete {
t.Errorf("method = %q, want DELETE", rec.method)
}
if !strings.Contains(rec.rawURL, "id=K1") {
t.Errorf("rawURL = %q, want id=K1", rec.rawURL)
}
}
func TestV1_ImportKey(t *testing.T) {
want := &models.GarageKeyInfo{AccessKeyID: "GKimported"}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.ImportKey(context.Background(), models.ImportKeyRequest{
AccessKeyID: "GKimported",
SecretAccessKey: "secret",
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/key/import" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if got.AccessKeyID != "GKimported" {
t.Errorf("got %q", got.AccessKeyID)
}
}
func TestV1_ListBuckets(t *testing.T) {
want := []models.ListBucketsResponseItem{{ID: "b1", GlobalAliases: []string{"mybucket"}}}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.ListBuckets(context.Background())
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodGet || rec.path != "/v1/bucket" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if len(got) != 1 || got[0].ID != "b1" {
t.Errorf("got %+v", got)
}
}
func TestV1_GetBucketInfo(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.GetBucketInfo(context.Background(), "b1")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(rec.rawURL, "id=b1") {
t.Errorf("rawURL = %q", rec.rawURL)
}
if got.ID != "b1" {
t.Errorf("got %q", got.ID)
}
}
func TestV1_GetBucketInfoByAlias(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b2"}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.GetBucketInfoByAlias(context.Background(), "myalias")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(rec.rawURL, "globalAlias=myalias") {
t.Errorf("rawURL = %q", rec.rawURL)
}
if got.ID != "b2" {
t.Errorf("got %q", got.ID)
}
}
func TestV1_CreateBucket(t *testing.T) {
want := &models.GarageBucketInfo{ID: "newb"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "test-bucket"
got, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{GlobalAlias: &alias})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/bucket" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if got.ID != "newb" {
t.Errorf("got %q", got.ID)
}
}
func TestV1_UpdateBucket(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
_, err := svc.UpdateBucket(context.Background(), "b1", models.UpdateBucketRequest{})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPut {
t.Errorf("method = %q, want PUT", rec.method)
}
if !strings.Contains(rec.rawURL, "id=b1") {
t.Errorf("rawURL = %q", rec.rawURL)
}
}
func TestV1_DeleteBucket(t *testing.T) {
svc, rec := newV1RecordingServer(t, 200, nil)
err := svc.DeleteBucket(context.Background(), "b1")
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodDelete {
t.Errorf("method = %q, want DELETE", rec.method)
}
if !strings.Contains(rec.rawURL, "id=b1") {
t.Errorf("rawURL = %q", rec.rawURL)
}
}
func TestV1_AllowBucketKey(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
_, err := svc.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{
BucketID: "b1", AccessKeyID: "k1",
Permissions: models.BucketKeyPermission{Read: true},
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/bucket/allow" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_DenyBucketKey(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
_, err := svc.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{
BucketID: "b1", AccessKeyID: "k1",
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/bucket/deny" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_AddBucketAlias_Global(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "myalias"
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{
BucketID: "b1", GlobalAlias: &alias,
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPut || rec.path != "/v1/bucket/alias/global" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if !strings.Contains(rec.rawURL, "id=b1") || !strings.Contains(rec.rawURL, "alias=myalias") {
t.Errorf("rawURL = %q", rec.rawURL)
}
}
func TestV1_AddBucketAlias_Local(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "localname"
keyID := "GK1"
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{
BucketID: "b1", LocalAlias: &alias, AccessKeyID: &keyID,
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPut || rec.path != "/v1/bucket/alias/local" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_RemoveBucketAlias_Global(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "myalias"
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{
BucketID: "b1", GlobalAlias: &alias,
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodDelete || rec.path != "/v1/bucket/alias/global" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_GetClusterStatus(t *testing.T) {
raw := map[string]any{
"node": "abc123",
"garageVersion": "1.3.0",
"knownNodes": []map[string]any{
{"id": "n1", "addr": "1.2.3.4:3901", "isUp": true, "hostname": "node1"},
{"id": "n2", "addr": "5.6.7.8:3901", "isUp": false, "lastSeenSecsAgo": 60, "hostname": "node2"},
},
"layout": map[string]any{"version": 3},
}
svc, rec := newV1RecordingServer(t, 200, raw)
got, err := svc.GetClusterStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if rec.path != "/v1/status" {
t.Errorf("path = %q, want /v1/status", rec.path)
}
if got.LayoutVersion != 3 {
t.Errorf("LayoutVersion = %d, want 3", got.LayoutVersion)
}
if len(got.Nodes) != 2 {
t.Fatalf("len(Nodes) = %d, want 2", len(got.Nodes))
}
if got.Nodes[0].ID != "n1" || !got.Nodes[0].IsUp {
t.Errorf("Nodes[0] = %+v", got.Nodes[0])
}
if got.Nodes[1].ID != "n2" || got.Nodes[1].IsUp {
t.Errorf("Nodes[1] = %+v", got.Nodes[1])
}
}
func TestV1_HealthCheck(t *testing.T) {
svc, rec := newV1RecordingServer(t, 200, nil)
err := svc.HealthCheck(context.Background())
if err != nil {
t.Fatal(err)
}
if rec.path != "/health" {
t.Errorf("path = %q, want /health", rec.path)
}
}
func TestV1_ErrorPaths(t *testing.T) {
// Server that returns 500 for all requests to exercise error branches.
srv500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
w.Write([]byte(`{"error":"internal"}`))
}))
t.Cleanup(srv500.Close)
svc := NewGarageV1AdminService(&config.GarageConfig{
AdminEndpoint: srv500.URL,
AdminToken: "tok",
}, "")
ctx := context.Background()
if _, err := svc.ListKeys(ctx); err == nil {
t.Error("ListKeys: expected error")
}
if _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); err == nil {
t.Error("CreateKey: expected error")
}
if _, err := svc.GetKeyInfo(ctx, "k", false); err == nil {
t.Error("GetKeyInfo: expected error")
}
if _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); err == nil {
t.Error("UpdateKey: expected error")
}
if err := svc.DeleteKey(ctx, "k"); err == nil {
t.Error("DeleteKey: expected error")
}
if _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); err == nil {
t.Error("ImportKey: expected error")
}
if _, err := svc.ListBuckets(ctx); err == nil {
t.Error("ListBuckets: expected error")
}
if _, err := svc.GetBucketInfo(ctx, "b"); err == nil {
t.Error("GetBucketInfo: expected error")
}
if _, err := svc.GetBucketInfoByAlias(ctx, "a"); err == nil {
t.Error("GetBucketInfoByAlias: expected error")
}
if _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); err == nil {
t.Error("CreateBucket: expected error")
}
if _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); err == nil {
t.Error("UpdateBucket: expected error")
}
if err := svc.DeleteBucket(ctx, "b"); err == nil {
t.Error("DeleteBucket: expected error")
}
if _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
t.Error("AllowBucketKey: expected error")
}
if _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
t.Error("DenyBucketKey: expected error")
}
alias := "a"
if _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
t.Error("AddBucketAlias: expected error")
}
if _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
t.Error("RemoveBucketAlias: expected error")
}
if _, err := svc.GetClusterHealth(ctx); err == nil {
t.Error("GetClusterHealth: expected error")
}
if _, err := svc.GetClusterStatus(ctx); err == nil {
t.Error("GetClusterStatus: expected error")
}
if err := svc.HealthCheck(ctx); err == nil {
t.Error("HealthCheck: expected error")
}
if _, err := svc.GetMetrics(ctx); err == nil {
t.Error("GetMetrics: expected error")
}
}
func TestV1_RequestFailurePaths(t *testing.T) {
// Use a cancelled context to make doRequest fail immediately (no retry wait).
svc, _ := newV1RecordingServer(t, 200, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
if _, err := svc.ListKeys(ctx); err == nil {
t.Error("ListKeys: expected error")
}
if _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); err == nil {
t.Error("CreateKey: expected error")
}
if _, err := svc.GetKeyInfo(ctx, "k", false); err == nil {
t.Error("GetKeyInfo: expected error")
}
if _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); err == nil {
t.Error("UpdateKey: expected error")
}
if err := svc.DeleteKey(ctx, "k"); err == nil {
t.Error("DeleteKey: expected error")
}
if _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); err == nil {
t.Error("ImportKey: expected error")
}
if _, err := svc.ListBuckets(ctx); err == nil {
t.Error("ListBuckets: expected error")
}
if _, err := svc.GetBucketInfo(ctx, "b"); err == nil {
t.Error("GetBucketInfo: expected error")
}
if _, err := svc.GetBucketInfoByAlias(ctx, "a"); err == nil {
t.Error("GetBucketInfoByAlias: expected error")
}
if _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); err == nil {
t.Error("CreateBucket: expected error")
}
if _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); err == nil {
t.Error("UpdateBucket: expected error")
}
if err := svc.DeleteBucket(ctx, "b"); err == nil {
t.Error("DeleteBucket: expected error")
}
if _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
t.Error("AllowBucketKey: expected error")
}
if _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
t.Error("DenyBucketKey: expected error")
}
alias := "a"
if _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
t.Error("AddBucketAlias: expected error")
}
if _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
t.Error("RemoveBucketAlias: expected error")
}
if _, err := svc.GetClusterHealth(ctx); err == nil {
t.Error("GetClusterHealth: expected error")
}
if _, err := svc.GetClusterStatus(ctx); err == nil {
t.Error("GetClusterStatus: expected error")
}
if err := svc.HealthCheck(ctx); err == nil {
t.Error("HealthCheck: expected error")
}
if _, err := svc.GetMetrics(ctx); err == nil {
t.Error("GetMetrics: expected error")
}
}
func TestV1_AddBucketAlias_MissingFields(t *testing.T) {
svc, _ := newV1RecordingServer(t, 200, nil)
// Neither globalAlias nor localAlias set
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{BucketID: "b"})
if err == nil {
t.Fatal("expected error for missing alias fields")
}
}
func TestV1_RemoveBucketAlias_MissingFields(t *testing.T) {
svc, _ := newV1RecordingServer(t, 200, nil)
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{BucketID: "b"})
if err == nil {
t.Fatal("expected error for missing alias fields")
}
}
func TestV1_RemoveBucketAlias_Local(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "localname"
keyID := "GK1"
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{
BucketID: "b1", LocalAlias: &alias, AccessKeyID: &keyID,
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodDelete || rec.path != "/v1/bucket/alias/local" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_GetMetrics(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(200)
w.Write([]byte("garage_up 1\n"))
}))
t.Cleanup(srv.Close)
svc := NewGarageV1AdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "tok",
}, "")
got, err := svc.GetMetrics(context.Background())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "garage_up") {
t.Errorf("got %q", got)
}
}
+3 -7
View File
@@ -9,7 +9,7 @@ import (
)
// AdminService is the set of Garage Admin API operations used by HTTP handlers.
// It is implemented by *GarageV2AdminService in admin_v2.go. Kept narrow so that
// It is implemented by *GarageAdminService in admin.go. Kept narrow so that
// hand-rolled mocks in tests don't need to cover admin methods the handlers
// never call.
type AdminService interface {
@@ -48,17 +48,14 @@ type AdminService interface {
// GetBucketStatistics) are intentionally excluded.
type S3Storage interface {
ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error)
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error)
ObjectExists(ctx context.Context, bucketName, key string) (bool, error)
DeleteObject(ctx context.Context, bucketName, key string) error
GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error)
DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error)
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error
UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
@@ -68,7 +65,6 @@ type S3Storage interface {
// Compile-time guarantees that the concrete services implement the interfaces.
var (
_ AdminService = (*GarageV2AdminService)(nil)
_ AdminService = (*GarageV1AdminService)(nil)
_ AdminService = (*GarageAdminService)(nil)
_ S3Storage = (*S3Service)(nil)
)
@@ -115,7 +115,7 @@ func TestS3Mock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
if _, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err == nil {
t.Error("GetPresignedURL: want error")
}
if _, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
t.Error("DeleteMultipleObjects: want error")
}
// UploadMultipleObjects has no error channel; it must return a result slice
@@ -163,7 +163,7 @@ func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
GetPresignedURLFn: func(_ context.Context, _, _ string, _ time.Duration) (string, error) {
return "http://x", nil
},
DeleteMultipleObjectsFn: func(_ context.Context, _ string, keys []string) (int, error) { return len(keys), nil },
DeleteMultipleObjectsFn: func(_ context.Context, _ string, _ []string) error { return nil },
UploadMultipleObjectsFn: func(_ context.Context, _ string, files []struct {
Key string
Body io.Reader
@@ -201,8 +201,8 @@ func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
if u, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err != nil || u == "" {
t.Errorf("GetPresignedURL = (%q, %v)", u, err)
}
if n, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil || n != 1 {
t.Errorf("DeleteMultipleObjects = (%d, %v)", n, err)
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil {
t.Errorf("DeleteMultipleObjects: %v", err)
}
results := m.UploadMultipleObjects(ctx, "b", []struct {
Key string
+10 -37
View File
@@ -23,18 +23,15 @@ var _ services.S3Storage = (*S3Mock)(nil)
// per-method function fields they care about; unset methods return
// s3NotConfigured.
type S3Mock struct {
ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
SearchObjectsFn func(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error)
UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
CreateDirectoryMarkerFn func(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
GetObjectRangeFn func(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error)
ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error)
DeleteObjectFn func(ctx context.Context, bucketName, key string) error
GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) (int, error)
DeleteObjectsByPrefixFn func(ctx context.Context, bucketName, prefix string) (int, error)
GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error)
DeleteObjectFn func(ctx context.Context, bucketName, key string) error
GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error
UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
@@ -58,14 +55,6 @@ func (m *S3Mock) ListObjects(ctx context.Context, bucketName, prefix string, max
return m.ListObjectsFn(ctx, bucketName, prefix, maxKeys, continuationToken)
}
func (m *S3Mock) SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error) {
m.record("SearchObjects", bucketName, prefix, search)
if m.SearchObjectsFn == nil {
return nil, s3NotConfigured("SearchObjects")
}
return m.SearchObjectsFn(ctx, bucketName, prefix, search)
}
func (m *S3Mock) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
m.record("UploadObject", bucketName, key, contentType)
if m.UploadObjectFn == nil {
@@ -90,14 +79,6 @@ func (m *S3Mock) GetObject(ctx context.Context, bucketName, key string) (io.Read
return m.GetObjectFn(ctx, bucketName, key)
}
func (m *S3Mock) GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error) {
m.record("GetObjectRange", bucketName, key)
if m.GetObjectRangeFn == nil {
return nil, s3NotConfigured("GetObjectRange")
}
return m.GetObjectRangeFn(ctx, bucketName, key, start, end)
}
func (m *S3Mock) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
m.record("ObjectExists", bucketName, key)
if m.ObjectExistsFn == nil {
@@ -130,22 +111,14 @@ func (m *S3Mock) GetPresignedURL(ctx context.Context, bucketName, key string, ex
return m.GetPresignedURLFn(ctx, bucketName, key, expiresIn)
}
func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error) {
func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
m.record("DeleteMultipleObjects", bucketName, keys)
if m.DeleteMultipleObjectsFn == nil {
return 0, s3NotConfigured("DeleteMultipleObjects")
return s3NotConfigured("DeleteMultipleObjects")
}
return m.DeleteMultipleObjectsFn(ctx, bucketName, keys)
}
func (m *S3Mock) DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error) {
m.record("DeleteObjectsByPrefix", bucketName, prefix)
if m.DeleteObjectsByPrefixFn == nil {
return 0, s3NotConfigured("DeleteObjectsByPrefix")
}
return m.DeleteObjectsByPrefixFn(ctx, bucketName, prefix)
}
func (m *S3Mock) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
+76 -260
View File
@@ -9,9 +9,9 @@ import (
"strings"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
logpkg "Noooste/garage-ui/pkg/logger"
"Noooste/garage-ui/pkg/utils"
"github.com/minio/minio-go/v7"
@@ -22,11 +22,11 @@ import (
type S3Service struct {
client *minio.Client
config *config.GarageConfig
adminService AdminService
adminService *GarageAdminService
}
// NewS3Service creates a new S3 service instance using MinIO SDK
func NewS3Service(cfg *config.GarageConfig, adminService AdminService) *S3Service {
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
// Create MinIO client for Garage
// trim http or https from endpoint
if strings.HasPrefix(cfg.Endpoint, "http://") {
@@ -53,77 +53,56 @@ func NewS3Service(cfg *config.GarageConfig, adminService AdminService) *S3Servic
}
}
// Operation is a bitmask of S3 permissions a call needs. Combine with bitwise
// OR (e.g. OpRead | OpWrite) when more than one is required.
type Operation byte
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) {
cacheKey := fmt.Sprintf("key:%s", bucketName)
cacheData := utils.GlobalCache.Get(cacheKey)
const (
OpRead Operation = 0x1
OpWrite Operation = 0x2
)
// satisfies reports whether perms grants every bit set in op.
func (op Operation) satisfies(perms models.BucketKeyPermission) bool {
if op&OpRead != 0 && !perms.Read {
return false
}
if op&OpWrite != 0 && !perms.Write {
return false
}
return true
}
func setKeyInCache(bucketName string, permissions models.BucketKeyPermission, creds *credentials.Credentials) {
canWrite := permissions.Write
canRead := permissions.Read
if canWrite {
key := fmt.Sprintf("key:%s:%d", bucketName, OpWrite)
utils.GlobalCache.Set(key, creds, time.Hour)
}
if canRead {
key := fmt.Sprintf("key:%s:%d", bucketName, OpRead)
utils.GlobalCache.Set(key, creds, time.Hour)
}
if canRead && canWrite {
key := fmt.Sprintf("key:%s:%d", bucketName, OpRead|OpWrite)
utils.GlobalCache.Set(key, creds, time.Hour)
}
}
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string, op Operation) (*credentials.Credentials, error) {
cacheKey := fmt.Sprintf("key:%s:%d", bucketName, op)
if cached := utils.GlobalCache.Get(cacheKey); cached != nil {
return cached.(*credentials.Credentials), nil
if cacheData != nil {
return cacheData.(*credentials.Credentials), nil
}
// Get bucket info from Garage Admin API
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get bucket info: %w", err)
}
// Find a key with read and write permissions
var accessKeyID, secretAccessKey string
for _, keyInfo := range bucketInfo.Keys {
if !op.satisfies(keyInfo.Permissions) {
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
continue
}
// Get key details with secret
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
if err != nil || keyDetails.SecretAccessKey == nil {
continue
if err != nil {
return nil, fmt.Errorf("failed to get key info: %w", err)
}
if keyDetails.SecretAccessKey != nil {
accessKeyID = keyDetails.AccessKeyID
secretAccessKey = *keyDetails.SecretAccessKey
break
}
creds := credentials.NewStaticV4(keyDetails.AccessKeyID, *keyDetails.SecretAccessKey, "")
setKeyInCache(bucketName, keyInfo.Permissions, creds)
return creds, nil
}
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
if accessKeyID == "" || secretAccessKey == "" {
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
}
// Create credentials
creds := credentials.NewStaticV4(accessKeyID, secretAccessKey, "")
// Cache credentials for 1 hour
utils.GlobalCache.Set(cacheKey, creds, time.Hour)
return creds, nil
}
// getMinioClient creates a MinIO client for a specific bucket with credentials
// that satisfy op.
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string, op Operation) (*minio.Client, error) {
creds, err := s.getBucketCredentials(ctx, bucketName, op)
// getMinioClient creates a MinIO client for a specific bucket with dynamic credentials
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*minio.Client, error) {
creds, err := s.getBucketCredentials(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
}
@@ -153,7 +132,7 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
return listErr
})
if err != nil {
return nil, fmt.Errorf("failed to list buckets: %w", err)
return nil, apierr.FromMinio(err)
}
// Convert MinIO buckets to our model
@@ -173,7 +152,7 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
// CreateBucket creates a new bucket in Garage
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
client, err := s.getMinioClient(ctx, bucketName, OpRead|OpWrite)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -186,7 +165,7 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
})
})
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
return apierr.FromMinio(err)
}
return nil
@@ -194,7 +173,7 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// DeleteBucket deletes a bucket from Garage
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
client, err := s.getMinioClient(ctx, bucketName, OpRead|OpWrite)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -205,7 +184,7 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
return client.RemoveBucket(ctx, bucketName)
})
if err != nil {
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
return apierr.FromMinio(err)
}
return nil
@@ -214,7 +193,7 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
// ListObjects lists objects in a bucket with optional prefix filter and pagination
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpRead)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -238,13 +217,20 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
return nil, apierr.FromMinio(err)
}
// Drop directory marker objects (zero-byte keys ending in "/"). Garage
// returns them in Contents, but the UI renders folders from Prefixes — a
// marker shown as both a folder and a file is confusing. Any marker not
// already covered by a CommonPrefix is promoted to Prefixes below.
contents := make([]minio.ObjectInfo, 0, len(result.Contents))
markerKeys := make([]string, 0)
for _, obj := range result.Contents {
if strings.HasSuffix(obj.Key, "/") && obj.Size == 0 {
// A marker whose key equals the current listing prefix is the
// folder itself — drop it entirely so it doesn't render as a
// nameless child of itself.
if obj.Key != prefix {
markerKeys = append(markerKeys, obj.Key)
}
@@ -325,101 +311,10 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
}, nil
}
const (
searchMaxScan = 10000 // stop after scanning this many objects
searchMaxResults = 1000 // stop after collecting this many matches
searchPageSize = 1000 // objects requested per ListObjectsV2 page
)
func objectMatchesSearch(key string, size int64, lowerQuery string) bool {
if strings.HasSuffix(key, "/") && size == 0 {
return false
}
return strings.Contains(strings.ToLower(key), lowerQuery)
}
// SearchObjects performs a recursive, best-effort substring search over object
// keys under the given prefix.
func (s *S3Service) SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error) {
client, err := s.getMinioClient(ctx, bucketName, OpRead)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
core := &minio.Core{Client: client}
lowerQuery := strings.ToLower(search)
matches := make([]models.ObjectInfo, 0, 64)
scanned := 0
truncated := false
token := ""
scan:
for {
result, err := core.ListObjectsV2(
bucketName,
prefix,
"",
token,
"",
searchPageSize,
)
if err != nil {
return nil, fmt.Errorf("failed to search objects in bucket %s: %w", bucketName, err)
}
for _, obj := range result.Contents {
scanned++
if objectMatchesSearch(obj.Key, obj.Size, lowerQuery) {
matches = append(matches, models.ObjectInfo{
Key: obj.Key,
Size: obj.Size,
LastModified: obj.LastModified,
ETag: obj.ETag,
StorageClass: obj.StorageClass,
})
if len(matches) >= searchMaxResults {
truncated = true
break scan
}
}
if scanned >= searchMaxScan {
truncated = true
break scan
}
}
if !result.IsTruncated || result.NextContinuationToken == "" {
break
}
token = result.NextContinuationToken
}
if truncated {
logpkg.FromCtx(ctx).Warn().
Str("bucket", bucketName).
Str("prefix", prefix).
Int("scanned", scanned).
Int("matches", len(matches)).
Msg("search hit scan/result cap; results are partial")
}
return &models.ObjectListResponse{
Bucket: bucketName,
Objects: matches,
Prefixes: []string{},
Count: len(matches),
IsTruncated: truncated,
// Search returns all matches up to the cap in one response; there is no
// token-based pagination for search results.
NextContinuationToken: "",
}, nil
}
// UploadObject uploads an object to a bucket
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -439,7 +334,7 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
return uploadErr
})
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
return nil, apierr.FromMinio(err)
}
return &models.ObjectUploadResponse{
@@ -457,7 +352,7 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
// size=0 forces a single PutObject request with Content-Length: 0, which
// Garage accepts as a directory marker.
func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) {
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -472,7 +367,7 @@ func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key s
return uploadErr
})
if err != nil {
return nil, fmt.Errorf("failed to create directory %s in bucket %s: %w", key, bucketName, err)
return nil, apierr.FromMinio(err)
}
return &models.ObjectUploadResponse{
@@ -487,7 +382,7 @@ func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key s
// GetObject retrieves an object from a bucket
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpRead)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -502,14 +397,14 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
return getErr
})
if err != nil {
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
return nil, nil, apierr.FromMinio(err)
}
// Get object info
stat, err := object.Stat()
if err != nil {
object.Close()
return nil, nil, fmt.Errorf("failed to get object info for %s in bucket %s: %w", key, bucketName, err)
return nil, nil, apierr.FromMinio(err)
}
// Create object info
@@ -524,38 +419,10 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
return object, objectInfo, nil
}
// GetObjectRange retrieves an inclusive byte range of an object. The caller
// resolves the range against the object size beforehand, so this method does
// not stat the object again.
func (s *S3Service) GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error) {
client, err := s.getMinioClient(ctx, bucketName, OpRead)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
opts := minio.GetObjectOptions{}
if err := opts.SetRange(start, end); err != nil {
return nil, fmt.Errorf("invalid range %d-%d for object %s: %w", start, end, key, err)
}
var object *minio.Object
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var getErr error
object, getErr = client.GetObject(ctx, bucketName, key, opts)
return getErr
})
if err != nil {
return nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
}
return object, nil
}
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -566,7 +433,7 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
return client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
return apierr.FromMinio(err)
}
return nil
@@ -575,7 +442,7 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
// ObjectExists checks if an object exists in a bucket
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpRead)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -595,7 +462,7 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
if errResponse.Code == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to check if object exists: %w", err)
return false, apierr.FromMinio(err)
}
return true, nil
}
@@ -603,7 +470,7 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
// GetObjectMetadata retrieves metadata for an object without downloading it
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpRead)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -618,7 +485,7 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
return statErr
})
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
return nil, apierr.FromMinio(err)
}
return &models.ObjectInfo{
@@ -632,23 +499,16 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
}, nil
}
// DeleteMultipleObjects deletes multiple objects from a bucket and returns the
// number of objects that were removed (requested keys minus any that failed).
//
// Note: S3/MinIO batch delete is idempotent — removing a key that does not
// exist succeeds and is not reported on the error channel, so it counts toward
// the returned total. The count therefore reflects "keys the delete operation
// did not fail on", which is the strongest signal obtainable without a
// per-key existence check.
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error) {
// DeleteMultipleObjects deletes multiple objects from a bucket
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
if len(keys) == 0 {
return 0, nil
return nil
}
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return 0, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Create channel for objects to delete
@@ -664,68 +524,24 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
}
}()
// Call MinIO RemoveObjects API (batch delete). RemoveObjects only surfaces
// the objects it FAILED to delete, so we drain the whole channel (which also
// avoids leaking the sender goroutine) and count failures.
// Call MinIO RemoveObjects API (batch delete)
errorCh := client.RemoveObjects(ctx, bucketName, objectsCh, minio.RemoveObjectsOptions{})
failed := 0
var firstErr error
for rerr := range errorCh {
if rerr.Err != nil {
failed++
if firstErr == nil {
firstErr = fmt.Errorf("failed to delete object %s from bucket %s: %w", rerr.ObjectName, bucketName, rerr.Err)
}
// Check for errors
for err := range errorCh {
if err.Err != nil {
return apierr.FromMinio(err.Err)
}
}
if firstErr != nil {
return len(keys) - failed, firstErr
}
return len(keys), nil
}
// DeleteObjectsByPrefix recursively deletes every object stored under the given
// prefix (i.e. a "folder"), including the directory marker itself. It returns
// the number of objects that were deleted.
func (s *S3Service) DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error) {
if prefix == "" {
return 0, fmt.Errorf("prefix is required for recursive delete")
}
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return 0, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// List every object under the prefix recursively (no delimiter), so nested
// folders are flattened into their concrete keys.
keys := make([]string, 0)
for obj := range client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
}) {
if obj.Err != nil {
return 0, fmt.Errorf("failed to list objects under prefix %s in bucket %s: %w", prefix, bucketName, obj.Err)
}
keys = append(keys, obj.Key)
}
if len(keys) == 0 {
return 0, nil
}
return s.DeleteMultipleObjects(ctx, bucketName, keys)
return nil
}
// GetPresignedURL generates a pre-signed URL for temporary access to an object
// This is useful for sharing files without exposing credentials
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpRead)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -764,7 +580,7 @@ func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string
results := make([]UploadResult, len(files))
// Get bucket-specific MinIO client once for all uploads
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
// If we can't get the client, all uploads fail
for i := range files {
+30 -188
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -13,6 +14,7 @@ import (
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
@@ -62,7 +64,7 @@ func newS3TestService(t *testing.T, s3Handler http.Handler) *S3Service {
srv := httptest.NewServer(combined)
t.Cleanup(srv.Close)
admin := NewGarageV2AdminService(&config.GarageConfig{
admin := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test",
}, "")
@@ -105,8 +107,9 @@ func TestS3_ListBuckets_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error from ListBuckets, got nil")
}
if !strings.Contains(err.Error(), "failed to list buckets") {
t.Errorf("error %v should wrap 'failed to list buckets'", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -122,8 +125,9 @@ func TestS3_CreateBucket_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create bucket") {
t.Errorf("error = %v, want wrap 'failed to create bucket'", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -138,8 +142,9 @@ func TestS3_DeleteBucket_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete bucket") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -154,8 +159,9 @@ func TestS3_ListObjects_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error from ListObjects, got nil")
}
if !strings.Contains(err.Error(), "failed to list objects") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -170,8 +176,9 @@ func TestS3_UploadObject_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to upload object") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -186,8 +193,9 @@ func TestS3_CreateDirectoryMarker_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create directory") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -215,8 +223,9 @@ func TestS3_DeleteObject_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete object") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -260,8 +269,9 @@ func TestS3_GetObjectMetadata_ServerError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to get metadata") {
t.Errorf("error = %v", err)
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
}
}
@@ -274,8 +284,8 @@ func TestS3_DeleteMultipleObjects_EmptyKeysIsNoop(t *testing.T) {
})
s3 := newS3TestService(t, h)
if n, err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil || n != 0 {
t.Fatalf("empty keys should return (0, nil), got (%d, %v)", n, err)
if err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil {
t.Fatalf("empty keys should return nil, got %v", err)
}
if called {
t.Error("S3 handler was invoked for empty-keys call")
@@ -289,114 +299,12 @@ func TestS3_DeleteMultipleObjects_ServerErrorPropagates(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
if err == nil {
t.Fatal("expected error, got nil")
}
}
// s3PrefixDeleteHandler serves a ListObjectsV2 response (GET) from listBody and
// a successful multi-object DeleteResult (POST /{bucket}?delete), recording how
// many batch-delete requests were made.
func s3PrefixDeleteHandler(listBody string, deletePosts *int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
*deletePosts++
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?><DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`)
return
}
// Any GET is treated as a ListObjectsV2 request.
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, listBody)
})
}
func TestS3_DeleteObjectsByPrefix_EmptyPrefixIsError(t *testing.T) {
// A blank prefix must be rejected before any network call — it would
// otherwise match (and delete) every object in the bucket.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
s3ErrorXML(w, http.StatusInternalServerError, "ShouldNotHappen", "")
})
s3 := newS3TestService(t, h)
n, err := s3.DeleteObjectsByPrefix(context.Background(), "b-TestS3_DeleteObjectsByPrefix_EmptyPrefixIsError", "")
if err == nil {
t.Fatal("empty prefix should return an error")
}
if n != 0 {
t.Errorf("count = %d, want 0", n)
}
if called {
t.Error("no S3 request should be made for an empty prefix")
}
}
func TestS3_DeleteObjectsByPrefix_ListsThenDeletes(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "docs/a"}, {Key: "docs/b"}, {Key: "docs/sub/c"},
}
listBody := listBucketResultXML("b", false, "", contents, nil)
deletePosts := 0
s3 := newS3TestService(t, s3PrefixDeleteHandler(listBody, &deletePosts))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
n, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_ListsThenDeletes", "docs/")
if err != nil {
t.Fatalf("DeleteObjectsByPrefix: %v", err)
}
if n != 3 {
t.Errorf("deleted = %d, want 3 (all objects listed under the prefix)", n)
}
if deletePosts == 0 {
t.Error("expected a batch-delete request to be made")
}
}
func TestS3_DeleteObjectsByPrefix_NoObjectsReturnsZero(t *testing.T) {
listBody := listBucketResultXML("b", false, "", nil, nil)
deletePosts := 0
s3 := newS3TestService(t, s3PrefixDeleteHandler(listBody, &deletePosts))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
n, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_NoObjectsReturnsZero", "empty/")
if err != nil {
t.Fatalf("DeleteObjectsByPrefix: %v", err)
}
if n != 0 {
t.Errorf("deleted = %d, want 0", n)
}
if deletePosts != 0 {
t.Errorf("no batch-delete should be made when nothing matches, got %d", deletePosts)
}
}
func TestS3_DeleteObjectsByPrefix_ListErrorPropagates(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_ListErrorPropagates", "docs/")
if err == nil {
t.Fatal("expected the list error to propagate, got nil")
}
}
func TestS3_GetPresignedURL_ReturnsURLWithoutServerCall(t *testing.T) {
// Presign is purely local (no network round-trip). Any handler suffices.
called := false
@@ -669,69 +577,3 @@ func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) {
}
}
}
func TestGetObjectRange_SendsRangeHeaderAndStreamsBody(t *testing.T) {
bucket := uniqueBucket2(t)
var gotRange string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRange = r.Header.Get("Range")
w.Header().Set("Content-Range", "bytes 2-6/10")
w.Header().Set("Content-Length", "5")
// The MinIO client parses Last-Modified from the response headers on
// the first Read, so the fake server must set a valid one.
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("23456"))
})
s3 := newS3TestService(t, handler)
body, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 2, 6)
if err != nil {
t.Fatalf("GetObjectRange: %v", err)
}
defer body.Close()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(data) != "23456" {
t.Errorf("body = %q, want %q", data, "23456")
}
if gotRange != "bytes=2-6" {
t.Errorf("Range header = %q, want %q", gotRange, "bytes=2-6")
}
}
func TestGetObjectRange_InvalidRangeRejectedLocally(t *testing.T) {
bucket := uniqueBucket2(t)
var called bool
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
s3 := newS3TestService(t, handler)
// end before start is a caller bug; SetRange rejects it before any request.
if _, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 6, 2); err == nil {
t.Fatal("expected error for inverted range")
}
if called {
t.Error("no S3 request should be sent for an invalid range")
}
}
func TestGetObjectRange_ClientAcquisitionFailurePropagates(t *testing.T) {
bucket := uniqueBucket(t)
mux := http.NewServeMux()
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
})
s3, _ := adminBackedS3(t, mux)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := s3.GetObjectRange(ctx, bucket, "missing", 0, 4); err == nil {
t.Fatal("expected error, got nil")
}
}
-121
View File
@@ -1,121 +0,0 @@
package services
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
// s3SearchHandler serves a two-page recursive ListObjectsV2 response and counts
// HEAD (StatObject) requests. Page selection is driven by the
// `continuation-token` query parameter, mirroring how the MinIO SDK paginates.
func s3SearchHandler(bucket string, page1, page2 []struct {
Key string
Size int64
LastModified string
ETag string
}) (http.Handler, *int, *string) {
var heads int
var lastDelimiter string
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
heads++
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusOK)
return
}
lastDelimiter = r.URL.Query().Get("delimiter")
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
if r.URL.Query().Get("continuation-token") == "PAGE2" {
_, _ = io.WriteString(w, listBucketResultXML(bucket, false, "", page2, nil))
return
}
_, _ = io.WriteString(w, listBucketResultXML(bucket, true, "PAGE2", page1, nil))
})
return h, &heads, &lastDelimiter
}
// TestS3_SearchObjects_FindsMatchOnLaterPage reproduces issue #87: an object
// that lives on the second page of a listing must still be found by search.
// SearchObjects should page through the whole listing recursively.
func TestS3_SearchObjects_FindsMatchOnLaterPage(t *testing.T) {
bucket := "b-TestS3_SearchObjects_FindsMatchOnLaterPage"
page1 := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "docs/alpha.txt", Size: 10},
{Key: "docs/beta.txt", Size: 10},
}
page2 := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "docs/target-report.pdf", Size: 20},
{Key: "docs/gamma.txt", Size: 10},
}
h, heads, lastDelimiter := s3SearchHandler(bucket, page1, page2)
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := s3.SearchObjects(ctx, bucket, "", "target")
if err != nil {
t.Fatalf("SearchObjects: %v", err)
}
if got.Count != 1 || len(got.Objects) != 1 || got.Objects[0].Key != "docs/target-report.pdf" {
t.Fatalf("expected to find docs/target-report.pdf on page 2, got %+v", got.Objects)
}
// Search must be recursive (no delimiter) so it descends into folders.
if *lastDelimiter != "" {
t.Errorf("delimiter = %q, want empty (recursive listing)", *lastDelimiter)
}
// Search must not fetch ContentType per object — that would be N stat calls.
if *heads != 0 {
t.Errorf("StatObject (HEAD) calls = %d, want 0 during search", *heads)
}
}
// TestS3_SearchObjects_ExcludesDirectoryMarkersAndIsCaseInsensitive verifies
// that zero-byte directory markers never appear as matches and that matching
// is a case-insensitive substring test on the full key.
func TestS3_SearchObjects_ExcludesDirectoryMarkersAndIsCaseInsensitive(t *testing.T) {
bucket := "b-TestS3_SearchObjects_ExcludesDirectoryMarkers"
page1 := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "Reports/", Size: 0}, // directory marker — must be excluded
{Key: "Reports/Q1-REPORT.csv", Size: 5}, // matches "report" case-insensitively
{Key: "images/logo.png", Size: 5}, // no match
}
h, _, _ := s3SearchHandler(bucket, page1, nil)
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := s3.SearchObjects(ctx, bucket, "", "report")
if err != nil {
t.Fatalf("SearchObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "Reports/Q1-REPORT.csv" {
t.Fatalf("expected only Reports/Q1-REPORT.csv, got %+v", got.Objects)
}
for _, o := range got.Objects {
if strings.HasSuffix(o.Key, "/") {
t.Errorf("directory marker leaked into results: %q", o.Key)
}
}
}
+10 -194
View File
@@ -3,7 +3,6 @@ package services
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -57,14 +56,14 @@ func TestNewS3Service_LeavesBareHostUnchanged(t *testing.T) {
}
}
// adminBackedS3 wires an S3Service to a fresh GarageV2AdminService that talks
// adminBackedS3 wires an S3Service to a fresh GarageAdminService that talks
// to the supplied http.Handler.
func adminBackedS3(t *testing.T, handler http.Handler) (*S3Service, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
admin := NewGarageV2AdminService(&config.GarageConfig{
admin := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}, "")
@@ -81,9 +80,7 @@ func uniqueBucket(t *testing.T) string {
t.Helper()
name := "test-bucket-" + t.Name()
t.Cleanup(func() {
for _, op := range []Operation{OpRead, OpWrite, OpRead | OpWrite} {
utils.GlobalCache.Delete(fmt.Sprintf("key:%s:%d", name, op))
}
utils.GlobalCache.Delete("key:" + name)
})
return name
}
@@ -114,7 +111,7 @@ func TestGetBucketCredentials_HappyPath(t *testing.T) {
})
s3, _ := adminBackedS3(t, mux)
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
creds, err := s3.getBucketCredentials(context.Background(), bucket)
if err != nil {
t.Fatalf("getBucketCredentials: %v", err)
}
@@ -155,7 +152,7 @@ func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) {
s3, _ := adminBackedS3(t, mux)
for i := range 3 {
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite); err != nil {
if _, err := s3.getBucketCredentials(context.Background(), bucket); err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
@@ -167,82 +164,7 @@ func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) {
}
}
func TestGetBucketCredentials_RWKeyWarmsAllTiers(t *testing.T) {
bucket := uniqueBucket(t)
secret := "rw-secret"
var bucketCalls, keyCalls int
mux := http.NewServeMux()
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
bucketCalls++
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
ID: "bid",
Keys: []models.BucketKeyInfo{
{AccessKeyID: "RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
keyCalls++
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "RW",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
// Prime via OpRead — should populate OpRead, OpWrite, and OpRead|OpWrite.
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead); err != nil {
t.Fatalf("prime OpRead: %v", err)
}
for _, op := range []Operation{OpWrite, OpRead | OpWrite, OpRead} {
if _, err := s3.getBucketCredentials(context.Background(), bucket, op); err != nil {
t.Fatalf("op %d: %v", op, err)
}
}
if bucketCalls != 1 {
t.Errorf("GetBucketInfo called %d times, want 1 (RW key should warm every tier)", bucketCalls)
}
if keyCalls != 1 {
t.Errorf("GetKeyInfo called %d times, want 1", keyCalls)
}
}
// A read-only key must NOT populate the write or RW cache slots, otherwise an
// OpWrite call would receive credentials the cluster will reject.
func TestGetBucketCredentials_ReadOnlyKeyDoesNotPoisonWriteCache(t *testing.T) {
bucket := uniqueBucket(t)
secret := "ro-secret"
mux := http.NewServeMux()
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
ID: "bid",
Keys: []models.BucketKeyInfo{
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "READ-ONLY",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
// Warm OpRead cache with the read-only key.
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead); err != nil {
t.Fatalf("prime OpRead: %v", err)
}
// OpWrite must still fail — the read-only key must not have leaked into
// the write cache slot.
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpWrite); err == nil {
t.Fatal("OpWrite served credentials from a read-only key; cache was poisoned")
}
}
func TestGetBucketCredentials_OpReadWriteSkipsKeysMissingAnyBit(t *testing.T) {
func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) {
bucket := uniqueBucket(t)
secret := "good-secret"
@@ -269,7 +191,7 @@ func TestGetBucketCredentials_OpReadWriteSkipsKeysMissingAnyBit(t *testing.T) {
})
s3, _ := adminBackedS3(t, mux)
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
creds, err := s3.getBucketCredentials(context.Background(), bucket)
if err != nil {
t.Fatalf("getBucketCredentials: %v", err)
}
@@ -282,112 +204,6 @@ func TestGetBucketCredentials_OpReadWriteSkipsKeysMissingAnyBit(t *testing.T) {
}
}
// Regression test for issue #44: read-only buckets must remain browsable when
// no read+write key is assigned. Before the fix, this case returned
// "no valid credentials found for bucket music" and the UI broke entirely.
func TestGetBucketCredentials_ReadOnlyFallsBackToReadKey(t *testing.T) {
bucket := uniqueBucket(t)
secret := "ro-secret"
mux := http.NewServeMux()
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
ID: "bid",
Keys: []models.BucketKeyInfo{
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "READ-ONLY",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead)
if err != nil {
t.Fatalf("getBucketCredentials: %v", err)
}
v, err := creds.GetWithContext(nil)
if err != nil {
t.Fatalf("creds.GetWithContext: %v", err)
}
if v.AccessKeyID != "READ-ONLY" {
t.Errorf("AccessKeyID = %q, want READ-ONLY", v.AccessKeyID)
}
}
// Even with only a read-only key available, asking for write credentials must
// still fail loudly so uploads/deletes return a meaningful error instead of
// silently using a key that the cluster will reject.
func TestGetBucketCredentials_ReadOnlyBucketRejectsWriteRequest(t *testing.T) {
bucket := uniqueBucket(t)
secret := "ro-secret"
mux := http.NewServeMux()
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
ID: "bid",
Keys: []models.BucketKeyInfo{
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "READ-ONLY",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
if err == nil {
t.Fatal("expected error when only a read-only key exists, got nil")
}
if !strings.Contains(err.Error(), "no valid credentials") {
t.Errorf("expected 'no valid credentials' in error, got %v", err)
}
}
// Mirror of issue #44 for write-only buckets: uploads must still succeed with a
// write-only key, even though no key grants read access.
func TestGetBucketCredentials_WriteOnlyFallsBackToWriteKey(t *testing.T) {
bucket := uniqueBucket(t)
secret := "wo-secret"
mux := http.NewServeMux()
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
ID: "bid",
Keys: []models.BucketKeyInfo{
{AccessKeyID: "WRITE-ONLY", Permissions: models.BucketKeyPermission{Read: false, Write: true}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "WRITE-ONLY",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpWrite)
if err != nil {
t.Fatalf("getBucketCredentials: %v", err)
}
v, err := creds.GetWithContext(nil)
if err != nil {
t.Fatalf("creds.GetWithContext: %v", err)
}
if v.AccessKeyID != "WRITE-ONLY" {
t.Errorf("AccessKeyID = %q, want WRITE-ONLY", v.AccessKeyID)
}
}
func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) {
bucket := uniqueBucket(t)
@@ -403,7 +219,7 @@ func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) {
})
s3, _ := adminBackedS3(t, mux)
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead)
_, err := s3.getBucketCredentials(context.Background(), bucket)
if err == nil {
t.Fatal("expected error when bucket has no keys, got nil")
}
@@ -438,7 +254,7 @@ func TestGetBucketCredentials_KeyWithoutSecretIsSkipped(t *testing.T) {
})
s3, _ := adminBackedS3(t, mux)
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
creds, err := s3.getBucketCredentials(context.Background(), bucket)
if err != nil {
t.Fatalf("getBucketCredentials: %v", err)
}
@@ -461,7 +277,7 @@ func TestGetBucketCredentials_AdminErrorPropagates(t *testing.T) {
})
s3, _ := adminBackedS3(t, mux)
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
_, err := s3.getBucketCredentials(context.Background(), bucket)
if err == nil {
t.Fatal("expected error when admin call fails, got nil")
}
+5 -51
View File
@@ -11,7 +11,6 @@ import (
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
appmw "Noooste/garage-ui/internal/middleware"
@@ -65,24 +64,10 @@ var version = "dev"
func main() {
// Parse command-line flags
configPath := flag.String("config", "config.yaml", "Path to configuration file")
garageTomlPath := flag.String("garage-toml", "", "Path to garage.toml file (extracts Garage connection values)")
flag.Parse()
// Env var fallback for --garage-toml
if *garageTomlPath == "" {
if envPath := os.Getenv("GARAGE_UI_GARAGE_TOML"); envPath != "" {
*garageTomlPath = envPath
}
}
// Build load options
var loadOpts []config.LoadOption
if *garageTomlPath != "" {
loadOpts = append(loadOpts, config.WithGarageToml(*garageTomlPath))
}
// Load configuration first (before initializing logger)
cfg, err := config.Load(*configPath, loadOpts...)
cfg, err := config.Load(*configPath)
if err != nil {
// If config fails to load, use default logger to report the error
logger.Get().Fatal().Err(err).Str("config_path", *configPath).Msg("Failed to load configuration")
@@ -102,21 +87,9 @@ func main() {
Str("environment", cfg.Server.Environment).
Msg("Starting Garage UI Backend")
if *garageTomlPath != "" {
logger.Warn().
Str("s3_endpoint", cfg.Garage.Endpoint).
Str("admin_endpoint", cfg.Garage.AdminEndpoint).
Msg("Endpoints inferred from garage.toml bind addresses — override with GARAGE_UI_GARAGE_ENDPOINT / GARAGE_UI_GARAGE_ADMIN_ENDPOINT for remote/container setups")
}
// Initialize services
logger.Info().Msg("Detecting Garage API version")
adminResult, err := services.NewAdminService(&cfg.Garage, cfg.Logging.Level)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to Garage admin API")
}
adminService := adminResult.Service
capabilitiesHandler := handlers.NewCapabilitiesHandler(adminResult.APIVersion, adminResult.Capabilities, cfg.AccessControl != nil)
logger.Info().Msg("Initializing Garage Admin service")
adminService := services.NewGarageAdminService(&cfg.Garage, cfg.Logging.Level)
logger.Info().Msg("Initializing S3 service")
s3Service := services.NewS3Service(&cfg.Garage, adminService)
@@ -129,9 +102,6 @@ func main() {
if cfg.Auth.OIDC.Enabled {
authMethods = append(authMethods, "oidc")
}
if cfg.Auth.Token.Enabled {
authMethods = append(authMethods, "token")
}
if len(authMethods) == 0 {
authMethods = append(authMethods, "none")
}
@@ -141,19 +111,10 @@ func main() {
logger.Fatal().Err(err).Msg("Failed to initialize auth service")
}
policy, err := authz.CompilePolicy(cfg.AccessControl)
if err != nil {
logger.Fatal().Err(err).Msg("Invalid access_control configuration")
}
if cfg.AccessControl != nil && !cfg.Auth.OIDC.Enabled {
logger.Warn().Msg("access_control is configured but OIDC is disabled: admin and token logins are always full-admin in v1, so the policy currently gates nothing")
}
azMiddleware := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, cfg.Auth.OIDC.EffectiveAdminRoles()), authz.NewAuthorizer())
// Initialize handlers
healthHandler := handlers.NewHealthHandler(version)
bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
objectHandler := handlers.NewObjectHandler(s3Service, authService)
objectHandler := handlers.NewObjectHandler(s3Service)
userHandler := handlers.NewUserHandler(adminService)
clusterHandler := handlers.NewClusterHandler(adminService)
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
@@ -221,25 +182,18 @@ func main() {
userHandler,
clusterHandler,
monitoringHandler,
capabilitiesHandler,
azMiddleware,
)
if err := authz.VerifyRouteCoverage(app); err != nil {
logger.Fatal().Err(err).Msg("authz route coverage check failed")
}
// Start server in a goroutine
go func() {
addr := cfg.GetAddress()
logger.Info().
Str("address", addr).
Str("network", fiber.NetworkTCP).
Str("health_endpoint", fmt.Sprintf("http://%s/health", addr)).
Str("api_docs", fmt.Sprintf("http://%s/api/v1/", addr)).
Msg("Server starting")
if err := app.Listen(addr, fiber.ListenConfig{ListenerNetwork: fiber.NetworkTCP}); err != nil {
if err := app.Listen(addr); err != nil {
logger.Fatal().Err(err).Msg("Failed to start server")
}
}()
@@ -15,6 +15,7 @@ services:
start_period: 2s
backend:
image: garage-ui-smoke-backend:ci
build:
context: ../../..
dockerfile: Dockerfile
-1
View File
@@ -10,7 +10,6 @@ coverage:
ignore:
- "backend/main.go"
- "backend/cmd/"
- "backend/docs/"
- "backend/internal/services/mocks/"
- "backend/**/*_mock.go"
+1 -45
View File
@@ -2,7 +2,7 @@
# Server configuration
server:
host: "::" # IPv6 wildcard; dual-stack behavior depends on OS/runtime socket settings
host: "0.0.0.0"
port: 8080
environment: "development" # development, production
domain: "localhost" # Domain name for the application
@@ -34,24 +34,12 @@ auth:
# The key is a 64-byte Ed25519 private key
jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key
# Expose Prometheus metrics at top-level /metrics WITHOUT authentication.
# Needed for Prometheus to scrape when auth (admin/token/oidc) is enabled.
# WARNING: exposes operational cluster telemetry (no object data or secrets)
# to anyone who can reach the port. Restrict with a NetworkPolicy / firewall.
metrics_public: false # Set to true to serve /metrics unauthenticated
# Admin Authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
username: "admin"
password: "changeme"
# Admin Token Authentication
# When enabled, users can log in using the Garage admin token
# Auto-enabled when no other auth method is configured (zero-config fallback)
token:
enabled: false # Set to true to explicitly enable, or leave all auth disabled for auto-enable
# OIDC Configuration
# NOTE: When OIDC is enabled, server.root_url is required for OAuth2 redirects
# The redirect URL will be automatically constructed as: {root_url}/auth/oidc/callback
@@ -81,16 +69,7 @@ auth:
# Role-based access (optional)
role_attribute_path: "resource_access.garage-ui.roles"
# Team-based access control (optional, see access_control below).
# team_attribute_path: "groups"
# Single admin role (backward-compatible).
admin_role: "admin"
# Multiple admin roles: a user is granted admin if ANY of their roles
# matches ANY entry below. Values from admin_role and admin_roles are
# merged, so you can set either, both, or only admin_roles.
# admin_roles:
# - "garage-admins"
# - "platform-team"
# TLS configuration
tls_skip_verify: false # Only set to true for testing, not recommended for production
@@ -102,29 +81,6 @@ auth:
cookie_http_only: true
cookie_same_site: "lax" # lax, strict, none
# Optional: team-based access control (see docs/access-control.md).
# Absent -> every authenticated user has full access.
# Present -> default-deny: OIDC users get only what their teams grant; users
# matching no team get 403 everywhere. admin_role users, admin
# password logins, and token logins are always full-admin.
# NOTE: this is UI-layer policy, NOT a security boundary. Anyone holding the
# Garage admin token or S3 keys bypasses it entirely.
#
# access_control:
# presets:
# bucket_readonly: [bucket.list, bucket.read, object.list, object.read]
# bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update,
# bucket.delete, object.write, object.delete]
# teams:
# - name: backend
# claim_values: ["garage-team-backend"] # matched against team_attribute_path claim
# bindings:
# - bucket_prefixes: ["backend-"]
# permissions: ["preset:bucket_owner"]
# - bucket_prefixes: ["shared-"]
# permissions: ["preset:bucket_readonly"]
# cluster_permissions: [cluster.status, cluster.health]
# CORS Configuration (for frontend)
cors:
enabled: true
-182
View File
@@ -1,182 +0,0 @@
# Multi-User Access Control
Garage UI can scope what each user sees and does, based on the teams in their OIDC claims. A typical setup: the backend team manages every `backend-*` bucket, can read the shared ones, and sees nothing else.
This is optional. If your config has no `access_control` section, every authenticated user has full access.
## Not a security boundary
Read this before relying on access control for anything sensitive.
Garage UI talks to Garage with a single admin token and a single set of S3 keys. Access control is enforced by the UI, not by Garage. Anyone who holds the underlying admin token or raw S3 keys bypasses it completely.
Use it to give each team a convenient, scoped view of the cluster. Don't use it as a substitute for real per-tenant credentials or network isolation.
## Setup
Get OIDC login working first. Access control only applies to OIDC users, and [config.example.yaml](../config.example.yaml) covers the OIDC settings.
From there, it takes two additions to your config:
1. `team_attribute_path` tells Garage UI which OIDC claim lists a user's teams.
2. `access_control` maps those teams to permissions.
```yaml
auth:
oidc:
# ...your existing OIDC settings...
team_attribute_path: "groups"
access_control:
presets:
bucket_readonly: [bucket.list, bucket.read, object.list, object.read]
bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update,
bucket.delete, object.write, object.delete]
teams:
- name: backend
claim_values: ["garage-team-backend"]
bindings:
- bucket_prefixes: ["backend-"]
permissions: ["preset:bucket_owner"]
- bucket_prefixes: ["shared-"]
permissions: ["preset:bucket_readonly"]
cluster_permissions: [cluster.status, cluster.health]
```
Reading the example top to bottom:
- `presets` are named permission bundles you can reuse across teams. They're optional; you can always list permissions directly.
- A user whose `groups` claim contains `garage-team-backend` lands in the `backend` team.
- That team owns buckets starting with `backend-`, can read buckets starting with `shared-`, and can view cluster status and health.
- Everyone else, including OIDC users who match no team, gets a 403 on everything. The moment `access_control` exists, the UI switches to default-deny.
A few things to know before writing your own:
- `team_attribute_path` is a [go-jmespath](https://github.com/jmespath/go-jmespath) expression evaluated against the OIDC claims, the same convention as `role_attribute_path`. It's required whenever `access_control.teams` is set and OIDC is enabled. If it's missing, the server refuses to start and the error says why.
- `access_control` can only be set in the config file. There's no environment variable for it, because nested team and binding lists don't fit flat `GARAGE_UI_*` variables.
- If `access_control` is present but OIDC is disabled, the server still starts but logs a warning. The policy would gate nothing, since admin-password and token logins are always full admin (see [Admins](#admins)).
### Check that it works
Log in as a test user and open `GET /api/v1/capabilities`. The `access_control` block in the response shows the user's resolved `bindings` and `cluster_permissions`. Empty arrays mean the user matched no team; [Troubleshooting](#troubleshooting) covers the usual reasons.
## How permissions are resolved
### Default-deny
Once `access_control` is set, an OIDC user who matches no team gets a 403 on every `/api/v1` endpoint. The one exception is `GET /api/v1/capabilities`, which returns their (empty) permissions so the frontend can show a "no access" screen.
### Union of teams
A user who matches several teams gets everything those teams grant.
Bindings stay separate, though. Say one binding grants `read` on `backend-*` and another grants `write` on `data-*`. The user does not end up with both permissions on both prefixes. Each binding keeps its own prefixes and its own permissions.
### Prefix match
`bucket_prefixes` are plain string prefixes on bucket names, with no globbing on the name itself. Use `"*"` to match every bucket.
### Presets
Reference a preset with the `preset:` prefix inside any `permissions` or `cluster_permissions` list, for example `"preset:bucket_owner"`. Presets can reference other presets. Unknown references and cycles both fail startup.
### Permission globs
A trailing-star glob like `bucket.*`, `object.*`, or `cluster.layout.*` expands against the permission vocabulary when the config loads. Keep globs inside their scope:
- `bucket.*` and `object.*` go in a binding's `permissions`
- `cluster.*`, `node.*`, `worker.*`, and `block.*` go under `cluster_permissions`
A bare `*` is technically a glob, but it almost always fails validation: it mixes prefix-scoped and global-scoped permissions, and a permission placed in the wrong scope is rejected at startup. Globs never expand to admin-only permissions, and there's no way to grant those to a team.
### Admins
These identities become a synthetic admin:
- OIDC users holding a configured `admin_role` / `admin_roles`
- all non-OIDC logins (admin password, Garage admin token)
An admin gets every permission on every bucket, plus every cluster permission. Admins go through the same authorizer as any team; there's no `IsAdmin` shortcut that skips the check.
### Startup validation
The server refuses to start when the policy is invalid: an unknown permission, an unknown or cyclic preset, an admin-only permission granted to a team, a duplicate team name, a team with empty `claim_values`, or a team with no bindings and no cluster permissions.
It also refuses to start if any `/api/v1` route has no declared permission, so a route can never ship un-gated (see [Troubleshooting](#troubleshooting)).
## Current limitations
- **Only OIDC users can be scoped to a team.** Admin-password and Garage-admin-token logins are always full admin.
- **`ListKeys` is not filtered.** Anyone with `key.list` sees every access key. Everything past `key.list` and `key.read` is admin-only: `key.read_secret`, `key.create`, `key.import`, `key.update`, `key.delete`.
- **No `admin_token.*` permissions.** Direct access to the raw Garage admin token is admin-only and not part of the vocabulary.
- **No ABAC, policy language, database-backed policy, or per-user grants.** Policy is YAML, compiled once at startup.
## Permission reference
Permission names are lowercase and dot-separated: two segments, or three for `cluster.layout.*`. The source of truth is `backend/internal/authz/vocabulary.go`; this table is maintained by hand, so update it whenever the registry changes.
| Permission | Scope | Admin-only | Garage endpoint / backing |
|---|---|---|---|
| `bucket.list` | prefix | | ListBuckets (response-filtered) |
| `bucket.read` | prefix | | GetBucketInfo |
| `bucket.create` | prefix | | CreateBucket (new name must match a prefix) |
| `bucket.update` | prefix | | UpdateBucket |
| `bucket.delete` | prefix | | DeleteBucket |
| `bucket.cleanup_uploads` | prefix | | CleanupIncompleteUploads |
| `bucket.inspect_object` | prefix | | InspectObject |
| `bucket_alias.add` | prefix | | AddBucketAlias |
| `bucket_alias.remove` | prefix | | RemoveBucketAlias |
| `object.list` | prefix | | S3 data plane (ListObjects) |
| `object.read` | prefix | | S3 data plane (Get/Head/Metadata/Presign; presign is download-only) |
| `object.write` | prefix | | S3 data plane (Upload, CreateDirectory) |
| `object.delete` | prefix | | S3 data plane (Delete, DeleteMultiple) |
| `permission.allow_bucket_key` | prefix | | AllowBucketKey |
| `permission.deny_bucket_key` | prefix | | DenyBucketKey |
| `key.list` | global | | ListKeys (unfiltered; grantee sees all keys) |
| `key.read` | global | | GetKeyInfo (without secret) |
| `key.read_secret` | global | yes | GetKeyInfo with secret material |
| `key.create` | global | yes | CreateKey |
| `key.import` | global | yes | ImportKey |
| `key.update` | global | yes | UpdateKey |
| `key.delete` | global | yes | DeleteKey |
| `cluster.status` | global | | GetClusterStatus |
| `cluster.health` | global | | GetClusterHealth |
| `cluster.statistics` | global | | GetClusterStatistics |
| `cluster.connect_nodes` | global | | ConnectClusterNodes |
| `cluster.layout.read` | global | | GetClusterLayout |
| `cluster.layout.history` | global | | GetClusterLayoutHistory |
| `cluster.layout.apply` | global | | ApplyClusterLayout |
| `cluster.layout.skip_dead_nodes` | global | | ClusterLayoutSkipDeadNodes |
| `node.info` | global | | GetNodeInfo |
| `node.statistics` | global | | GetNodeStatistics |
| `node.snapshot` | global | | CreateMetadataSnapshot |
| `node.repair` | global | | LaunchRepairOperation |
| `worker.list` | global | | ListWorkers |
| `worker.info` | global | | GetWorkerInfo |
| `worker.get_variable` | global | | GetWorkerVariable |
| `worker.set_variable` | global | | SetWorkerVariable |
| `block.list_errors` | global | | ListBlockErrors |
| `block.info` | global | | GetBlockInfo |
Some permissions have no UI route yet: `bucket.cleanup_uploads`, `bucket.inspect_object`, `bucket_alias.*`, `cluster.layout.*`, `worker.*`, `block.*`, `cluster.connect_nodes`, `node.snapshot`, `node.repair`, and `key.import`. They're valid in config but don't gate anything in the UI so far. The vocabulary is complete up front so your config keeps working as the UI grows.
Dangerous operations (`cluster.layout.apply`, `node.repair`, `worker.set_variable`) are separate, individually grantable permissions. They're never bundled into a read-only preset, so you can give a team cluster visibility without also giving it the power to break the cluster.
One endpoint needs two permissions: `POST /api/v1/buckets/:name/permissions` performs an allow and a deny in a single call, so it requires both `permission.allow_bucket_key` and `permission.deny_bucket_key`. One of the two alone is not enough.
## Troubleshooting
- **A user gets 403s they shouldn't.** Open `GET /api/v1/capabilities` while logged in as that user. The `access_control` block shows their resolved `bindings` and `cluster_permissions` (empty arrays mean they matched no team). Check that the IdP actually sends the claim named by `team_attribute_path`, and that its values match a team's `claim_values` exactly. It's a plain string match, with no wildcards on the claim value.
- **403 responses name the missing permission.** The message is `Missing permission: <permission.name>`. That's the exact permission that was denied, so you know which binding, preset, or `cluster_permissions` entry to add.
- **Decision logs.** Every check logs one line, `authz_decision`, with fields `subject`, `action`, `resource`, `decision` (`allow` / `deny`), and `reason` (such as `binding_match`, `any_binding`, `no_matching_binding`, `cluster_permission`, `no_cluster_permission`, `no_subject`). Denials log at `warn`, allows at `debug`. Set `logging.level` to `debug` to see successful checks too. There's no separate audit log; this goes through the normal application logger.
- **Startup fails with `access_control: ...` or `authz: routes without Require permission declaration: ...`.** Both are intentional fail-closed checks, not bugs:
- An invalid policy (unknown permission, bad preset reference, admin-only permission handed to a team, duplicate team name, empty `claim_values`, or a team with no bindings or cluster permissions) stops startup with an error naming the problem.
- A `/api/v1` route wired without a permission requirement also stops startup. This is a safety net for developers, not something you trigger by editing config, but it can show up after a `git pull` that adds a route without its enforcement wiring.
## See also
- [config.example.yaml](../config.example.yaml): the full commented `access_control` example.
- [garage-setup.md](garage-setup.md): general Garage UI and Garage setup.
-59
View File
@@ -1,59 +0,0 @@
# Setting Up a Garage Cluster
This guide walks you through setting up a local Garage cluster using Docker Compose for use with Garage UI.
If you already have a running Garage cluster, skip this and go straight to the [Quick Start](../README.md#quick-start).
## Prerequisites
- Docker & Docker Compose
## 1. Start Garage
From the garage-ui repository root:
```bash
docker compose up -d garage
sleep 10
```
## 2. Initialize the Cluster Layout
```bash
# Assign the node to a zone with 1GB capacity
docker compose exec garage garage layout assign -z dc1 -c 1G $(docker compose exec garage garage node id -q)
# Apply the layout
docker compose exec garage garage layout apply --version 1
```
## 3. Create an Admin Key
```bash
docker compose exec garage garage key create admin-key
```
Save the **access key** and **secret key** from the output — you'll need them for configuration.
## 4. Configure Garage UI
Copy the example config and fill in your Garage endpoints and admin token:
```bash
cp config.example.yaml config.yaml
```
The `admin_token` can be found in your `garage.toml` file. See [Garage Configuration](../README.md#garage-configuration) for the required `garage.toml` settings.
## 5. Start Garage UI
```bash
docker compose up -d garage-ui
```
Access Garage UI at http://localhost:8080
## Next Steps
- [Configuration reference](../config.example.yaml) for all available options
- [Garage official documentation](https://garagehq.deuxfleurs.fr/documentation/) for advanced Garage setup
+861 -2023
View File
File diff suppressed because it is too large Load Diff
+6 -15
View File
@@ -7,27 +7,23 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.18.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.16.0",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -37,24 +33,19 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.10",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.12",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^8.0.16",
"vitest": "^4.1.10"
"vite": "^7.2.4"
}
}
@@ -1,71 +0,0 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TokenLoginForm() {
const [token, setToken] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { loginToken } = useAuthStore();
const returnUrl = searchParams.get('returnUrl') || '/';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await loginToken(token);
navigate(decodeURIComponent(returnUrl));
} catch (error) {
console.error('Login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<img
src="/garage.png"
alt="Garage Logo"
className="h-16 w-16 object-contain"
/>
</div>
<CardTitle className="text-2xl text-center">
Welcome to Garage UI
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="admin-token" className="text-sm font-medium">Admin Token</label>
<Input
id="admin-token"
type="password"
placeholder="Enter your Garage admin token"
value={token}
onChange={(e) => setToken(e.target.value)}
required
disabled={isLoading}
autoComplete="off"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !token}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -12,7 +12,6 @@ import {
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
import { useBucketCan } from '@/hooks/usePermissions';
import type { Bucket } from '@/types';
interface BucketListViewProps {
@@ -36,7 +35,6 @@ export function BucketListView({
onDeleteBucket,
onWebsiteSettings,
}: BucketListViewProps) {
const canBucket = useBucketCan();
const filteredBuckets = buckets.filter((bucket) =>
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
);
@@ -120,39 +118,31 @@ export function BucketListView({
<FolderIcon className="h-4 w-4" />
View Objects
</DropdownMenuItem>
{canBucket(bucket, 'bucket.update') && (
<>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onOpenSettings(bucket);
}}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onWebsiteSettings(bucket);
}}>
<Globe className="h-4 w-4" />
Website Settings
</DropdownMenuItem>
</>
)}
{canBucket(bucket, 'bucket.delete') && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBucket(bucket);
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onOpenSettings(bucket);
}}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onWebsiteSettings(bucket);
}}>
<Globe className="h-4 w-4" />
Website Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBucket(bucket);
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

Some files were not shown because too many files have changed in this diff Show More