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
125 changed files with 2401 additions and 9324 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 -4
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,7 +64,6 @@ garage.toml
backend/docs/
config.yaml
docs/**/*.md
!docs/garage-setup.md
!docs/access-control.md
**/worktrees
# Superpowers brainstorm / session scratch
.superpowers/
-3
View File
@@ -1,3 +0,0 @@
{
".": "0.9.0"
}
-98
View File
@@ -1,98 +0,0 @@
# Changelog
## [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
+88 -135
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,31 +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
- 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
@@ -58,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
@@ -78,108 +135,22 @@ helm install garage-ui garage-ui/garage-ui \
--set garage.adminToken=your-token
```
Access at http://localhost:8080
### Quick Start with garage.toml
If you already have a running Garage instance, you can point Garage UI directly at your `garage.toml` -- no `config.yaml` needed:
## Development
Backend (Go 1.25+):
```bash
./garage-ui --garage-toml /etc/garage.toml
cd backend
go run main.go --config ../config.yaml
```
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 containers this won't work -- override the endpoint explicitly with environment variables or a config file.
**Docker:**
Frontend (Node.js 25+):
```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
cd frontend
npm install
npm run dev
```
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/Kubernetes secret integration, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui reads 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 causes startup to fail.
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 do not need this — the chart already injects secrets via `existingSecret` references.
API docs: http://localhost:8080/api/v1/
## Garage Configuration
@@ -201,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:**
@@ -256,12 +210,11 @@ Ideas being considered. Contributions welcome.
- [ ] Per-bucket usage graph over time
**Access keys**
- [ ] Permission matrix view (keys x buckets)
- [ ] Permission matrix view (keys × buckets)
- [ ] Key rotation helper
- [ ] Copy-ready snippets per key (aws-cli, rclone, restic, s3cmd, mc, Terraform)
**Cluster**
- [X] Support Garage v1 to latest
- [ ] Visual layout editor with staged vs. applied diff
- [ ] Capacity planner / simulation
- [ ] Rebalance progress and node health timeline
@@ -286,4 +239,4 @@ MIT - see [LICENSE](LICENSE)
- [Issues](https://github.com/Noooste/garage-ui/issues)
- [Contributing](CONTRIBUTING.md)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
+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),
-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
}
-235
View File
@@ -1,235 +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()
}
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()
}
-235
View File
@@ -1,235 +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 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)
}
}
+13 -192
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
@@ -51,7 +46,6 @@ type GarageConfig struct {
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)
}
@@ -62,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"`
@@ -82,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"`
@@ -93,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"`
@@ -132,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"
@@ -190,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()
@@ -225,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 {
@@ -245,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,9 +165,6 @@ func bindEnvVars() {
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
// 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")
viper.BindEnv("auth.oidc.provider_name", "GARAGE_UI_AUTH_OIDC_PROVIDER_NAME")
@@ -308,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")
@@ -331,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
@@ -414,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")
}
}
@@ -431,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 -501
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
-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) {
+17 -52
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
@@ -20,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
@@ -89,7 +90,6 @@ func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
// @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"
@@ -110,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")
@@ -138,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))
@@ -202,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))
@@ -254,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))
@@ -297,9 +276,7 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
// 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
@@ -361,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 {
@@ -374,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
@@ -423,9 +396,7 @@ 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)
}
return c.JSON(models.SuccessResponse(metadata))
@@ -483,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 {
@@ -497,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{
@@ -556,9 +523,7 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
return apierr.Respond(c, err)
}
response := models.ObjectDeleteMultipleResponse{
+42 -54
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"
@@ -120,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) {
@@ -190,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)
}
}
@@ -409,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)
}
}
@@ -910,3 +869,32 @@ func TestCreateDirectory_ServiceError500(t *testing.T) {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestDeleteObject_UpstreamNoSuchKey(t *testing.T) {
app, s3 := newObjectsTestApp(t)
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",
}
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/foo.txt", 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)
}
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)
}
if body.Error.Message != "The specified key does not exist" {
t.Errorf("message = %q", body.Error.Message)
}
}
+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 -17
View File
@@ -1,8 +1,6 @@
package middleware
import (
"strings"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
@@ -19,14 +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()
}
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)
@@ -82,18 +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, "+")
}
+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"`
}
+3 -6
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
@@ -201,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"
)
+38 -70
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))
@@ -55,34 +52,28 @@ func SetupRoutes(
// 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.
@@ -119,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)
@@ -161,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)
}
@@ -241,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
@@ -258,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 {
@@ -1,231 +0,0 @@
package routes
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"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),
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")
}
}
@@ -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)
}
}
-10
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,
@@ -75,8 +67,6 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
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}
@@ -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)
}
}
+2 -4
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,7 +48,6 @@ 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)
@@ -66,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)
)
+7 -16
View File
@@ -23,15 +23,14 @@ 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)
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)
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
@@ -56,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 {
+67 -172
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
@@ -527,7 +422,7 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
// 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)
}
@@ -538,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
@@ -547,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)
}
@@ -567,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
}
@@ -575,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)
}
@@ -590,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{
@@ -611,7 +506,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
}
// 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)
}
@@ -635,7 +530,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
// Check for errors
for err := range errorCh {
if err.Err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", err.ObjectName, bucketName, err.Err)
return apierr.FromMinio(err.Err)
}
}
@@ -646,7 +541,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
// 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)
}
@@ -685,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 {
+27 -17
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)
}
}
-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")
}
+4 -50
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,15 +111,6 @@ 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)
@@ -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 -39
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
@@ -40,12 +40,6 @@ auth:
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
@@ -75,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
@@ -96,29 +81,6 @@ auth:
cookie_http_only: true
cookie_same_site: "lax" # lax, strict, none
# Optional: team-based access control (issue #33).
# Absent -> every authenticated user has full access (historical behavior).
# 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 in v1.
# 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
-170
View File
@@ -1,170 +0,0 @@
# Multi-User Access Control
Garage UI can limit what each user sees and does, based on the teams in their OIDC claims.
It's optional. With no config, every authenticated user has full access, exactly like before.
## Not a security boundary
Read this before using access control for anything sensitive.
Garage UI talks to Garage with one admin token and one set of S3 keys. Access control lives in the UI only; Garage itself does not enforce it. Anyone holding the underlying admin token or raw S3 keys bypasses it completely.
Use it to give teams a convenient, scoped UI. Don't use it as a replacement for real per-tenant credentials or network isolation.
## Configuration
Two settings drive access control:
1. `team_attribute_path`: the OIDC claim that lists a user's teams.
2. `access_control`: maps teams to permissions.
```yaml
auth:
oidc:
# Existing keys unchanged. New:
team_attribute_path: "groups" # go-jmespath, same convention as role_attribute_path
access_control: # absent = full access for everyone; present = default-deny
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 the 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]
```
A few things to know:
- `team_attribute_path` is a [go-jmespath](https://github.com/jmespath/go-jmespath) expression evaluated against the OIDC claims, the same way `role_attribute_path` works. It's required when `access_control.teams` is set and OIDC is on. If it's missing, startup fails with a clear error.
- `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 off, the server still starts but logs a warning. Without OIDC users the policy gates nothing, since admin-password and token logins are always full admin (see [Admin model](#admin-model)).
## How it works
### Default-deny
With `access_control` 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 (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 config loads. Use scoped globs:
- `bucket.*`, `object.*` inside a binding's `permissions`
- `cluster.*`, `node.*`, `worker.*`, `block.*` 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 include admin-only permissions, and in v1 there's no team-level way to grant those.
### Admin model
These identities become a synthetic admin:
- OIDC users with 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 run 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)).
## Not in v1
- **No non-OIDC team mapping.** Admin-password and Garage-admin-token logins are always full admin. Only OIDC users can be scoped to a team.
- **`ListKeys` is not filtered.** Anyone with `key.list` sees every access key. Everything past `key.list` / `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 vocabulary (v1)
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 mirrors it by hand, and there's no doc generation in v1, so update the table whenever you change the registry.
| Permission | Scope | Admin-only v1 | 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 in v1; 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 yet. 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.
`POST /api/v1/buckets/:name/permissions` sets permissions by doing an allow and a deny in one call, so it needs **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 (string match, 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 in v1; 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 build-time safety net, 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
+866 -723
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -14,7 +14,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.16.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -23,7 +23,7 @@
"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",
@@ -36,16 +36,16 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@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",
"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"
"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>
@@ -6,7 +6,7 @@ import {ObjectsTable} from './ObjectsTable';
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
import {DeleteObjectDialog} from './DeleteObjectDialog';
import {UploadProgress} from './UploadProgress';
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, ScanSearch, Search, Trash, Upload} from 'lucide-react';
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
import {getBreadcrumbs} from '@/lib/file-utils';
import type {S3Object, UploadTask} from '@/types';
@@ -15,21 +15,18 @@ interface ObjectBrowserViewProps {
objects: S3Object[];
currentPath: string;
searchQuery: string;
filterQuery: string;
deepSearch: boolean;
isLoading?: boolean;
isTruncated?: boolean;
nextContinuationToken?: string;
itemsPerPage: number;
onSearchChange: (query: string) => void;
onDeepSearchChange: (enabled: boolean) => void;
onNavigateToFolder: (path: string) => void;
onBackToBuckets: () => void;
onUploadFiles?: (files: File[]) => Promise<boolean>;
onUploadFiles: (files: File[]) => Promise<boolean>;
uploadTasks: UploadTask[];
onDeleteObject?: (key: string) => Promise<boolean>;
onDeleteMultipleObjects?: (keys: string[]) => Promise<boolean>;
onCreateDirectory?: (name: string) => Promise<boolean>;
onDeleteObject: (key: string) => Promise<boolean>;
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
onCreateDirectory: (name: string) => Promise<boolean>;
onRefresh: () => Promise<void>;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
@@ -44,14 +41,11 @@ export function ObjectBrowserView({
objects,
currentPath,
searchQuery,
filterQuery,
deepSearch,
isLoading = false,
isTruncated = false,
nextContinuationToken,
itemsPerPage,
onSearchChange,
onDeepSearchChange,
onNavigateToFolder,
onBackToBuckets,
onUploadFiles,
@@ -75,8 +69,6 @@ export function ObjectBrowserView({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles, _fileRejections, event) => {
if (!onUploadFiles) return;
// Get files with their full paths from DataTransferItems API
const filesWithPaths: File[] = [];
@@ -103,7 +95,6 @@ export function ObjectBrowserView({
setShowUploadZone(false);
},
noClick: true,
disabled: !onUploadFiles,
});
// Helper function to traverse file/directory tree
@@ -156,14 +147,13 @@ export function ObjectBrowserView({
};
const handleBulkDeleteFiles = async () => {
if (!onDeleteMultipleObjects || selectedFileKeys.size === 0) return;
if (selectedFileKeys.size === 0) return;
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
setSelectedFileKeys(new Set());
};
const handleDeleteObject = async (key: string): Promise<boolean> => {
if (!onDeleteObject) return false;
const success = await onDeleteObject(key);
if (success) {
setDeleteObjectDialogOpen(false);
@@ -173,7 +163,6 @@ export function ObjectBrowserView({
};
const uploadFiles = async (files: File[]) => {
if (!onUploadFiles) return;
await onUploadFiles(files);
setShowUploadZone(false);
};
@@ -210,34 +199,17 @@ export function ObjectBrowserView({
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="flex flex-1 items-center gap-2 max-w-full sm:max-w-md">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={deepSearch ? 'Deep search names…' : 'Search by name prefix…'}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<Button
type="button"
variant={deepSearch ? 'primary' : 'secondary'}
onClick={() => onDeepSearchChange(!deepSearch)}
aria-pressed={deepSearch}
title={
deepSearch
? 'Deep search: ON. Matches names anywhere and descends into subfolders. Scans the bucket, results may be partial on very large buckets. Click for fast prefix search.'
: 'Fast prefix search: matches the start of object names in this folder (like the AWS S3 / Cloudflare R2 console). Click to enable deep search (substring + subfolders).'
}
className="shrink-0"
>
<ScanSearch className="h-4 w-4" />
<span className="hidden sm:inline">Deep</span>
</Button>
<div className="relative flex-1 max-w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search objects..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{onDeleteMultipleObjects && selectedFileKeys.size > 0 && (
{selectedFileKeys.size > 0 && (
<Button
onClick={handleBulkDeleteFiles}
title={`Delete ${selectedFileKeys.size} selected file(s)`}
@@ -247,18 +219,14 @@ export function ObjectBrowserView({
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
</Button>
)}
{onUploadFiles && (
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Upload</span>
</Button>
)}
{onCreateDirectory && (
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
<FolderPlus className="h-4 w-4" />
<span className="hidden sm:inline">Add Directory</span>
</Button>
)}
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Upload</span>
</Button>
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
<FolderPlus className="h-4 w-4" />
<span className="hidden sm:inline">Add Directory</span>
</Button>
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
@@ -266,7 +234,7 @@ export function ObjectBrowserView({
</div>
{/* Upload Zone */}
{onUploadFiles && showUploadZone && uploadTasks.length === 0 && (
{showUploadZone && uploadTasks.length === 0 && (
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
<div className="flex gap-6">
<div className="flex-shrink-0 flex items-center justify-center">
@@ -384,8 +352,6 @@ export function ObjectBrowserView({
objects={objects}
currentPath={currentPath}
searchQuery={searchQuery}
filterQuery={filterQuery}
deepSearch={deepSearch}
selectedFileKeys={selectedFileKeys}
isDragActive={isDragActive}
isLoading={isLoading && !isRefreshing && !isNavigating}
@@ -393,10 +359,10 @@ export function ObjectBrowserView({
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onNavigateToFolder={onNavigateToFolder}
onDeleteObject={onDeleteObject ? (obj) => {
onDeleteObject={(obj) => {
setSelectedObject(obj);
setDeleteObjectDialogOpen(true);
} : undefined}
}}
onToggleFileSelection={handleToggleFileSelection}
onSelectAllFiles={handleSelectAllFiles}
onPageChange={onPageChange}
@@ -408,14 +374,12 @@ export function ObjectBrowserView({
</div>
{/* Create Directory Dialog */}
{onCreateDirectory && (
<CreateDirectoryDialog
open={createDirDialogOpen}
onOpenChange={setCreateDirDialogOpen}
currentPath={currentPath}
onCreateDirectory={onCreateDirectory}
/>
)}
<CreateDirectoryDialog
open={createDirDialogOpen}
onOpenChange={setCreateDirDialogOpen}
currentPath={currentPath}
onCreateDirectory={onCreateDirectory}
/>
{/* Delete Object Dialog */}
<DeleteObjectDialog
@@ -1,8 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { objectsApi } from '@/lib/api';
import { useBuckets } from '@/hooks/useApi';
import { useBucketCan } from '@/hooks/usePermissions';
import type { ObjectMetadata } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -10,7 +8,7 @@ import { IconTile } from '@/components/ui/icon-tile';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { downloadObject, formatBytes } from '@/lib/file-utils';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
function CardSection({ title, children }: { title: string; children: React.ReactNode }) {
@@ -38,11 +36,6 @@ export function ObjectDetailsView() {
const { bucketName, '*': encodedObjectKey } = useParams();
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
const { data: buckets = [] } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const canBucket = useBucketCan();
const canDelete = canBucket(bucket, 'object.delete');
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -80,9 +73,22 @@ export function ObjectDetailsView() {
toast.success(label);
};
const handleDownload = () => {
const handleDownload = async () => {
if (!bucketName || !objectKey) return;
downloadObject(bucketName, objectKey);
try {
const blob = await objectsApi.get(bucketName, objectKey);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName || 'download';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Download started');
} catch {
// error toast handled by axios interceptor
}
};
const handleDelete = async () => {
@@ -168,11 +174,9 @@ export function ObjectDetailsView() {
<Button variant="secondary" onClick={handleDownload}>
<Download className="h-4 w-4" /> Download
</Button>
{canDelete && (
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
)}
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</div>
</section>
+56 -100
View File
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu';
import {ChevronLeft, ChevronRight, Download, Eye, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
import {Select, SelectOption} from '@/components/ui/select';
import {downloadObject, formatBytes, formatRelativeTime} from '@/lib/file-utils';
import {formatBytes, formatRelativeTime} from '@/lib/file-utils';
import type {S3Object} from '@/types';
interface ObjectsTableProps {
@@ -22,8 +22,6 @@ interface ObjectsTableProps {
objects: S3Object[];
currentPath: string;
searchQuery: string;
filterQuery: string;
deepSearch: boolean;
selectedFileKeys: Set<string>;
isDragActive: boolean;
isLoading?: boolean;
@@ -31,7 +29,7 @@ interface ObjectsTableProps {
nextContinuationToken?: string;
itemsPerPage: number;
onNavigateToFolder: (key: string) => void;
onDeleteObject?: (object: S3Object) => void;
onDeleteObject: (object: S3Object) => void;
onToggleFileSelection: (key: string) => void;
onSelectAllFiles: () => void;
onPageChange: (token?: string) => void;
@@ -48,8 +46,6 @@ export function ObjectsTable({
objects,
currentPath,
searchQuery,
filterQuery,
deepSearch,
selectedFileKeys,
isDragActive,
isLoading = false,
@@ -66,7 +62,6 @@ export function ObjectsTable({
initialItemsPerPage,
}: ObjectsTableProps) {
const navigate = useNavigate();
const canDelete = Boolean(onDeleteObject);
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
@@ -91,9 +86,7 @@ export function ObjectsTable({
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
const filteredObjects = useMemo(() => {
// Filter on the debounced query, not the raw input, so the list only
// updates once typing pauses (matches the debounced server request).
const query = filterQuery.toLowerCase();
const query = searchQuery.toLowerCase();
const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query));
return [...filtered].sort((a, b) => {
const aIsFolder = a.isFolder ? 1 : 0;
@@ -103,8 +96,8 @@ export function ObjectsTable({
let compareValue = 0;
switch (sortColumn) {
case 'name': {
const aName = a.key.replace(currentPath, '').replace(/\/$/, '').toLowerCase();
const bName = b.key.replace(currentPath, '').replace(/\/$/, '').toLowerCase();
const aName = a.key.replace(currentPath, '').replace('/', '').toLowerCase();
const bName = b.key.replace(currentPath, '').replace('/', '').toLowerCase();
compareValue = aName.localeCompare(bName);
break;
}
@@ -121,15 +114,13 @@ export function ObjectsTable({
return sortDirection === 'asc' ? compareValue : -compareValue;
});
}, [objects, filterQuery, sortColumn, sortDirection, currentPath]);
}, [objects, searchQuery, sortColumn, sortDirection, currentPath]);
// Effect 2: Reset pagination on path navigation or when a search begins/ends.
// Search results are a single flat list, so page-token state must not leak
// across the search/browse boundary.
// Effect 2: Reset pagination ONLY on path navigation
useEffect(() => {
setPageTokens([undefined]);
setCurrentPageIndex(0);
}, [currentPath, searchQuery, deepSearch]);
}, [currentPath]);
// Update page tokens when we get a new next token
useEffect(() => {
@@ -146,33 +137,11 @@ export function ObjectsTable({
}
}, [nextContinuationToken, isTruncated, currentPageIndex]);
// Prefix search and normal browsing are server-paginated (query folded into
// the prefix; continuation tokens for pages). Deep search loads the whole
// capped result set in one response, so we paginate that on the client by
// itemsPerPage instead of dumping every match at once.
const isDeepSearching = deepSearch && searchQuery.trim().length > 0;
const clientPaginated = isDeepSearching;
const totalPages = clientPaginated
? Math.max(1, Math.ceil(filteredObjects.length / itemsPerPage))
: 1;
// Clamp during render (not via a setState effect) so a shrinking result set
// or a larger page size can't strand us on an out-of-range page.
const pageIndex = clientPaginated ? Math.min(currentPageIndex, totalPages - 1) : currentPageIndex;
const pageObjects = clientPaginated
? filteredObjects.slice(pageIndex * itemsPerPage, (pageIndex + 1) * itemsPerPage)
: filteredObjects;
const hasPrevious = pageIndex > 0;
const hasNext = clientPaginated ? pageIndex < totalPages - 1 : isTruncated;
const hasPrevious = currentPageIndex > 0;
const hasNext = isTruncated;
const handleNextPage = () => {
if (!hasNext) return;
// Client-paginated (deep search): just advance the slice, no server fetch.
if (clientPaginated) {
setCurrentPageIndex(pageIndex + 1);
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
if (nextContinuationToken) {
if (hasNext && nextContinuationToken) {
const nextIndex = currentPageIndex + 1;
setCurrentPageIndex(nextIndex);
onPageChange(nextContinuationToken);
@@ -181,16 +150,13 @@ export function ObjectsTable({
};
const handlePreviousPage = () => {
if (!hasPrevious) return;
if (clientPaginated) {
setCurrentPageIndex(pageIndex - 1);
if (hasPrevious) {
const prevIndex = currentPageIndex - 1;
setCurrentPageIndex(prevIndex);
const previousToken = pageTokens[prevIndex];
onPageChange(previousToken);
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
const prevIndex = currentPageIndex - 1;
setCurrentPageIndex(prevIndex);
onPageChange(pageTokens[prevIndex]);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleItemsPerPageChange = (value: string) => {
@@ -215,18 +181,16 @@ export function ObjectsTable({
<Table>
<TableHeader>
<TableRow>
{canDelete && (
<TableHead className="w-[50px]">
<Checkbox
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
/>
</TableHead>
)}
<TableHead className="w-[50px]">
<Checkbox
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
/>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('name')}
@@ -253,7 +217,7 @@ export function ObjectsTable({
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12">
<TableCell colSpan={7} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading objects...</span>
@@ -262,7 +226,7 @@ export function ObjectsTable({
</TableRow>
) : filteredObjects.length === 0 ? (
<TableRow>
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12 text-muted-foreground">
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
{searchQuery
? 'No objects found matching your search'
: isDragActive
@@ -271,26 +235,24 @@ export function ObjectsTable({
</TableCell>
</TableRow>
) : (
pageObjects.map((obj) => (
filteredObjects.map((obj) => (
<TableRow key={obj.key}>
{canDelete && (
<TableCell className="w-[50px]">
{obj.isFolder ? (
<Checkbox
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
/>
) : (
<Checkbox
checked={selectedFileKeys.has(obj.key)}
onCheckedChange={() => onToggleFileSelection(obj.key)}
aria-label={`Select file ${obj.key}`}
/>
)}
</TableCell>
)}
<TableCell className="w-[50px]">
{obj.isFolder ? (
<Checkbox
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
/>
) : (
<Checkbox
checked={selectedFileKeys.has(obj.key)}
onCheckedChange={() => onToggleFileSelection(obj.key)}
aria-label={`Select file ${obj.key}`}
/>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{obj.isFolder ? (
@@ -303,7 +265,7 @@ export function ObjectsTable({
onClick={() => onNavigateToFolder(obj.key)}
className="font-medium cursor-pointer underline hover:text-primary"
>
{obj.key.replace(currentPath, '').replace(/\/$/, '')}
{obj.key.replace(currentPath, '').replace('/', '')}
</button>
) : (
<button
@@ -391,22 +353,18 @@ export function ObjectsTable({
<Eye className="h-4 w-4" />
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => downloadObject(bucketName, obj.key)}>
<DropdownMenuItem>
<Download className="h-4 w-4" />
Download
</DropdownMenuItem>
{onDeleteObject && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteObject(obj)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteObject(obj)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -437,9 +395,7 @@ export function ObjectsTable({
{/* Pagination info and controls */}
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{isDeepSearching
? `Page ${pageIndex + 1} of ${totalPages}${filteredObjects.length} match${filteredObjects.length !== 1 ? 'es' : ''}${isTruncated ? ' (capped, refine to narrow)' : ''}`
: `Page ${pageIndex + 1} • Showing ${pageObjects.length} item${pageObjects.length !== 1 ? 's' : ''}`}
Page {currentPageIndex + 1} Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-2">
@@ -5,21 +5,19 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { useBuckets } from '@/hooks/useApi';
import { useBucketCan } from '@/hooks/usePermissions';
import { toast } from 'sonner';
interface TabSpec {
to: string;
label: string;
end?: boolean;
perms?: string[];
}
const tabs: TabSpec[] = [
{ to: 'objects', label: 'Objects', perms: ['object.list'] },
{ to: 'permissions', label: 'Permissions', perms: ['permission.allow_bucket_key', 'permission.deny_bucket_key'] },
{ to: 'website', label: 'Website', perms: ['bucket.update'] },
{ to: 'settings', label: 'Settings', perms: ['bucket.update'] },
{ to: 'objects', label: 'Objects' },
{ to: 'permissions', label: 'Permissions' },
{ to: 'website', label: 'Website' },
{ to: 'settings', label: 'Settings' },
];
function formatBytes(n?: number) {
@@ -38,8 +36,6 @@ export function BucketDetailShell() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const { data: buckets = [] } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const canBucket = useBucketCan();
const visibleTabs = tabs.filter((t) => !t.perms || t.perms.every((p) => canBucket(bucket, p)));
const s3Url = `s3://${bucketName}`;
const copyUrl = async () => {
@@ -72,18 +68,16 @@ export function BucketDetailShell() {
<Button variant="secondary" onClick={copyUrl}>
<Copy /> Copy URL
</Button>
{canBucket(bucket, 'object.write') && (
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
<Upload /> Upload
</Button>
)}
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
<Upload /> Upload
</Button>
</div>
</div>
</section>
{/* Tabs */}
<nav className="flex h-12 items-center gap-0 border-b border-[var(--border)] px-7">
{visibleTabs.map((t) => (
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
+1 -4
View File
@@ -5,8 +5,6 @@ import { useState, useMemo } from 'react';
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { BreadcrumbItem } from '@/components/ui/breadcrumb';
import { usePermissions } from '@/hooks/usePermissions';
import { NoAccess } from '@/pages/NoAccess';
function useCrumbs(): BreadcrumbItem[] {
const location = useLocation();
@@ -37,7 +35,6 @@ function useCrumbs(): BreadcrumbItem[] {
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const crumbs = useCrumbs();
const { noAccess } = usePermissions();
return (
<div className="flex h-screen overflow-hidden bg-[var(--background)]">
@@ -62,7 +59,7 @@ export function Layout() {
<div className="flex min-w-0 flex-1 flex-col">
<TopBar crumbs={crumbs} />
<main className="flex-1 overflow-y-auto scrollbar-thin">
{noAccess ? <NoAccess /> : <Outlet />}
<Outlet />
</main>
</div>
</div>
+35 -44
View File
@@ -4,13 +4,11 @@ import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useQuery } from '@tanstack/react-query';
import { healthApi, garageApi } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface NavItem {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
visible?: (p: ReturnType<typeof usePermissions>) => boolean;
}
interface NavGroup {
@@ -24,15 +22,13 @@ const navGroups: NavGroup[] = [
},
{
label: 'Storage',
items: [
{ title: 'Buckets', href: '/buckets', icon: Database, visible: (p) => p.hasAnyPerm('bucket.list') },
],
items: [{ title: 'Buckets', href: '/buckets', icon: Database }],
},
{
label: 'Cluster',
items: [
{ title: 'Cluster', href: '/cluster', icon: Server, visible: (p) => p.hasAnyClusterAccess },
{ title: 'Access Control', href: '/access', icon: Key, visible: (p) => p.hasClusterPerm('key.list') },
{ title: 'Cluster', href: '/cluster', icon: Server },
{ title: 'Access Control', href: '/access', icon: Key },
],
},
];
@@ -45,7 +41,6 @@ interface SidebarProps {
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation();
const { config } = useAuthStore();
const perms = usePermissions();
const { data: uiVersion } = useQuery({
queryKey: ['ui-version'],
@@ -82,42 +77,38 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
<span className="text-[18px] font-semibold tracking-tight">Garage UI</span>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5 scrollbar-thin">
{navGroups.map((group, gi) => {
const visibleItems = group.items.filter((item) => !item.visible || item.visible(perms));
if (visibleItems.length === 0) return null;
return (
<div key={gi}>
{group.label && (
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
{group.label}
</div>
)}
<ul className="space-y-0.5">
{visibleItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
onClick={onClose}
className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
active
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
)}
>
<Icon className="h-4 w-4" />
{item.title}
</Link>
</li>
);
})}
</ul>
</div>
);
})}
{navGroups.map((group, gi) => (
<div key={gi}>
{group.label && (
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
{group.label}
</div>
)}
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
onClick={onClose}
className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
active
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
)}
>
<Icon className="h-4 w-4" />
{item.title}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
<a
+1 -1
View File
@@ -151,7 +151,7 @@ const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<H
<div
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none',
className
)}
onClick={(e) => {
+3 -5
View File
@@ -20,7 +20,7 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
/>
<div
className={cn(
'relative h-6 w-11 rounded-full border-2 transition-colors',
'relative h-6 w-11 rounded-full border transition-colors',
checked ? 'border-[#ff9329] bg-[#ff9329]' : 'border-[#6b7280] bg-[#6b7280]',
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
@@ -28,10 +28,8 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
)}
>
<span
className={cn(
'absolute top-0 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform',
checked ? 'translate-x-full' : 'translate-x-0'
)}
className="absolute left-[2px] h-5 w-5 rounded-full bg-white shadow transition-transform"
style={{ top: '50%', transform: `translateY(-50%) translateX(${checked ? '20px' : '0px'})` }}
/>
</div>
</label>
-1
View File
@@ -1,3 +1,2 @@
export { useDashboardData, useBuckets } from './useApi';
export { useBucketObjects } from './useBucketObjects';
export { usePermissions, bucketCan, useBucketCan } from './usePermissions';
-21
View File
@@ -63,27 +63,6 @@ export function useGrantBucketPermission() {
});
}
export function useUpdateBucketQuotas() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
bucketName,
maxSize,
maxObjects,
}: {
bucketName: string;
maxSize: number | null;
maxObjects: number | null;
}) => bucketsApi.updateBucketQuotas(bucketName, { maxSize, maxObjects }),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucketName) });
toast.success('Quotas updated successfully');
},
});
}
export function useObjects(bucket: string, prefix?: string, enabled = true) {
return useQuery({

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