mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6766dec933 | |||
| a061500d50 | |||
| 985b7b30ce | |||
| 8f6cb2b1da | |||
| 0e6b7b9215 | |||
| c0976a1e1d | |||
| 5d33382e30 | |||
| 477b544be7 | |||
| 90dffe594a | |||
| 9f73d032e6 | |||
| 16dc2eb996 | |||
| 0d804fbd39 | |||
| 3098e474f2 | |||
| b28e975a56 | |||
| 430d1a5d68 | |||
| 460793632e | |||
| f888b7c77c | |||
| ef118dc3a7 | |||
| 34ba8ef851 | |||
| 1ffdfe83c8 | |||
| e0cd59dcdf | |||
| 1c1e4d4b2b | |||
| ae245c8a31 | |||
| 28c186f3eb | |||
| 46aa3752c8 | |||
| d502dac457 | |||
| 67d8f633b0 | |||
| 22be89b2ff | |||
| ae97dd8f01 | |||
| 45f8770799 | |||
| e3191c2686 | |||
| 24997db960 | |||
| 3c69cc5f26 | |||
| 4b0e98008b | |||
| bc67e50606 | |||
| fe1765597c | |||
| 1c9043c697 |
@@ -3,11 +3,15 @@ name: chart-release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'garage-ui-chart-v*'
|
||||
- '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:
|
||||
@@ -26,7 +30,7 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.3.1
|
||||
with:
|
||||
version: v3.19.3
|
||||
version: v4.1.3
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.7.0
|
||||
@@ -35,3 +39,43 @@ jobs:
|
||||
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 }}"
|
||||
|
||||
@@ -25,87 +25,3 @@ jobs:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Sync Chart.yaml appVersion on release PR
|
||||
if: ${{ steps.rp.outputs.prs_created == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PRS: ${{ steps.rp.outputs.prs }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$PRS" | jq -c '.[]' | while read -r pr; do
|
||||
branch=$(echo "$pr" | jq -r '.headBranchName')
|
||||
workdir=$(mktemp -d)
|
||||
git clone --branch "$branch" --depth 1 \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$workdir"
|
||||
cd "$workdir"
|
||||
git config user.email "garage-ui-release-bot[bot]@users.noreply.github.com"
|
||||
git config user.name "garage-ui-release-bot[bot]"
|
||||
|
||||
version=$(jq -r '."."' .release-please-manifest.json)
|
||||
target="v${version}"
|
||||
sed -i -E "s|^appVersion:.*|appVersion: ${target}|" helm/garage-ui/Chart.yaml
|
||||
|
||||
if ! git diff --quiet helm/garage-ui/Chart.yaml; then
|
||||
git add helm/garage-ui/Chart.yaml
|
||||
git commit -m "chore: sync Chart.yaml appVersion to ${target}"
|
||||
git push origin "$branch"
|
||||
fi
|
||||
cd -
|
||||
rm -rf "$workdir"
|
||||
done
|
||||
|
||||
- name: Cut chart patch when backend released without chart
|
||||
if: ${{ steps.rp.outputs.releases_created == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
BACKEND_RELEASED: ${{ steps.rp.outputs.release_created }}
|
||||
CHART_RELEASED: ${{ steps.rp.outputs['helm/garage-ui--release_created'] }}
|
||||
BACKEND_TAG: ${{ steps.rp.outputs.tag_name }}
|
||||
BACKEND_VERSION: ${{ steps.rp.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BACKEND_RELEASED" != "true" ] || [ "$CHART_RELEASED" = "true" ]; then
|
||||
echo "Backend not released, or chart already released this cycle. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
workdir=$(mktemp -d)
|
||||
git clone \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$workdir"
|
||||
cd "$workdir"
|
||||
git config user.email "garage-ui-release-bot[bot]@users.noreply.github.com"
|
||||
git config user.name "garage-ui-release-bot[bot]"
|
||||
|
||||
chart_old=$(jq -r '."helm/garage-ui"' .release-please-manifest.json)
|
||||
chart_new=$(echo "$chart_old" | awk -F. '{printf "%d.%d.%d", $1, $2, $3+1}')
|
||||
chart_tag="garage-ui-chart-v${chart_new}"
|
||||
|
||||
jq --arg v "$chart_new" '."helm/garage-ui" = $v' \
|
||||
.release-please-manifest.json > .release-please-manifest.json.tmp
|
||||
mv .release-please-manifest.json.tmp .release-please-manifest.json
|
||||
|
||||
sed -i -E "s|^version:.*|version: ${chart_new}|" helm/garage-ui/Chart.yaml
|
||||
sed -i -E "s|^appVersion:.*|appVersion: ${BACKEND_TAG}|" helm/garage-ui/Chart.yaml
|
||||
|
||||
today=$(date -u +%Y-%m-%d)
|
||||
compare_url="https://github.com/${REPO}/compare/garage-ui-chart-v${chart_old}...${chart_tag}"
|
||||
entry=$(printf '## [%s](%s) (%s)\n\n\n### Miscellaneous Chores\n\n* sync chart appVersion to %s\n' \
|
||||
"$chart_new" "$compare_url" "$today" "$BACKEND_TAG")
|
||||
awk -v entry="$entry" 'NR==1 && /^# Changelog/ { print; print ""; print entry; next } { print }' \
|
||||
helm/garage-ui/CHANGELOG.md > helm/garage-ui/CHANGELOG.md.tmp
|
||||
mv helm/garage-ui/CHANGELOG.md.tmp helm/garage-ui/CHANGELOG.md
|
||||
|
||||
git add .release-please-manifest.json helm/garage-ui/Chart.yaml helm/garage-ui/CHANGELOG.md
|
||||
git commit -m "chore: bump chart to ${chart_new} for backend ${BACKEND_TAG}"
|
||||
git push origin main
|
||||
|
||||
git tag "$chart_tag"
|
||||
git push origin "$chart_tag"
|
||||
|
||||
gh release create "$chart_tag" \
|
||||
--repo "$REPO" \
|
||||
--title "$chart_tag" \
|
||||
--notes "Chart patch synced to backend ${BACKEND_TAG}. No chart template changes."
|
||||
|
||||
@@ -65,5 +65,8 @@ backend/docs/
|
||||
config.yaml
|
||||
docs/**/*.md
|
||||
!docs/garage-setup.md
|
||||
!docs/access-control.md
|
||||
**/state**
|
||||
**/content**
|
||||
|
||||
**/worktrees
|
||||
@@ -1,4 +1,3 @@
|
||||
{
|
||||
".": "0.8.0",
|
||||
"helm/garage-ui": "0.6.0"
|
||||
".": "0.10.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## [0.10.0](https://github.com/Noooste/garage-ui/compare/v0.9.0...v0.10.0) (2026-07-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* bulk actions — recursively delete folders (key prefixes) ([#68](https://github.com/Noooste/garage-ui/issues/68)) ([5d33382](https://github.com/Noooste/garage-ui/commit/5d33382e3056afd5b75b1775255e0f3033833f6c))
|
||||
* **metrics:** add public metrics endpoint configuration and update documentation ([#92](https://github.com/Noooste/garage-ui/issues/92)) ([9f73d03](https://github.com/Noooste/garage-ui/commit/9f73d032e6c1c10b7d0dafa986e32985aa3b6f66))
|
||||
* **preview:** Implement object preview functionality ([#94](https://github.com/Noooste/garage-ui/issues/94)) ([c0976a1](https://github.com/Noooste/garage-ui/commit/c0976a1e1d53839d1fe9d7158e8cadb979789722))
|
||||
|
||||
## [0.9.0](https://github.com/Noooste/garage-ui/compare/v0.8.5...v0.9.0) (2026-07-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backend,frontend:** implement prefix and recursive substring search for bucket objects ([#89](https://github.com/Noooste/garage-ui/issues/89)) ([3098e47](https://github.com/Noooste/garage-ui/commit/3098e474f202f36b527c8636cd1d4e8c08e287d2))
|
||||
* **oidc:** add fine grained access control ([#91](https://github.com/Noooste/garage-ui/issues/91)) ([0d804fb](https://github.com/Noooste/garage-ui/commit/0d804fbd39ed656511b671d237ad14fc8c21786d))
|
||||
|
||||
## [0.8.5](https://github.com/Noooste/garage-ui/compare/v0.8.4...v0.8.5) (2026-07-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** add downloadObject function for downloading files from a bucket ([430d1a5](https://github.com/Noooste/garage-ui/commit/430d1a5d68e4f9915a2951d5b31c1b5ea56cb24c))
|
||||
|
||||
## [0.8.4](https://github.com/Noooste/garage-ui/compare/v0.8.3...v0.8.4) (2026-06-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **helm:** track appVersion in release-please and fix badges ([1ffdfe8](https://github.com/Noooste/garage-ui/commit/1ffdfe83c884dc5e39d071343d5269164746c536))
|
||||
|
||||
## [0.8.3](https://github.com/Noooste/garage-ui/compare/v0.8.2...v0.8.3) (2026-06-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** improve API version detection with retry logic for health probes ([46aa375](https://github.com/Noooste/garage-ui/commit/46aa3752c81788787388d1c67e29cef786bdabff))
|
||||
* **helm:** update version badges in README for Garage UI ([28c186f](https://github.com/Noooste/garage-ui/commit/28c186f3eba1a1c111100712f1eaec22a5d18eb2))
|
||||
|
||||
## [0.8.2](https://github.com/Noooste/garage-ui/compare/v0.8.1...v0.8.2) (2026-06-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** prevent OIDC login loop from empty cookie name ([#76](https://github.com/Noooste/garage-ui/issues/76)) ([22be89b](https://github.com/Noooste/garage-ui/commit/22be89b2ff86465abb90dab0344ef9366ab181b3))
|
||||
|
||||
## [0.8.1](https://github.com/Noooste/garage-ui/compare/v0.8.0...v0.8.1) (2026-05-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** align three-dot menu item icon spacing and text alignment ([#72](https://github.com/Noooste/garage-ui/issues/72)) ([45f8770](https://github.com/Noooste/garage-ui/commit/45f87707996e92d0f8f75e79c8f60a13556eaf6e))
|
||||
|
||||
## [0.8.0](https://github.com/Noooste/garage-ui/compare/v0.7.0...v0.8.0) (2026-05-31)
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garag
|
||||
- **Access key management** - create keys, assign per-bucket permissions
|
||||
- **Cluster overview** - monitor node status, layout configuration, and storage usage
|
||||
- **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
|
||||
- **Multi-user access control** - optional OIDC-team-based permissions, see [docs/access-control.md](docs/access-control.md)
|
||||
- **Easy deployment** - single Docker image or Helm chart, configure with one YAML file
|
||||
- **Preview common file types** - images, video, PDF, and text without downloading
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -77,11 +79,17 @@ helm install garage-ui garage-ui/garage-ui \
|
||||
--set garage.adminToken=your-token
|
||||
```
|
||||
|
||||
Access at http://localhost:8080
|
||||
The chart creates a ClusterIP service on port 80. To try it out before setting up an ingress:
|
||||
|
||||
### Quick Start with garage.toml
|
||||
```bash
|
||||
kubectl port-forward svc/garage-ui 8080:80
|
||||
```
|
||||
|
||||
If you already have a running Garage instance, you can point Garage UI directly at your `garage.toml` -- no `config.yaml` needed:
|
||||
Then open http://localhost:8080
|
||||
|
||||
### Reusing your garage.toml
|
||||
|
||||
If you already have a running Garage instance, you can point Garage UI straight at your `garage.toml` and skip `config.yaml` entirely:
|
||||
|
||||
```bash
|
||||
./garage-ui --garage-toml /etc/garage.toml
|
||||
@@ -89,7 +97,7 @@ If you already have a running Garage instance, you can point Garage UI directly
|
||||
|
||||
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.
|
||||
**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside a container this won't work, so override the endpoints explicitly with environment variables or a config file.
|
||||
|
||||
**Docker:**
|
||||
|
||||
@@ -151,7 +159,7 @@ 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.
|
||||
For Docker and Kubernetes secrets, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui uses the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file stops startup.
|
||||
|
||||
Supported vars:
|
||||
|
||||
@@ -178,7 +186,7 @@ secrets:
|
||||
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.
|
||||
This matches the convention used by the official Postgres and MySQL Docker images. Helm users don't need it; the chart already injects secrets via `existingSecret` references.
|
||||
|
||||
## Garage Configuration
|
||||
|
||||
@@ -233,49 +241,19 @@ logging:
|
||||
|
||||
## Roadmap
|
||||
|
||||
Ideas being considered. Contributions welcome.
|
||||
Roughly ordered by value. Open an [issue](https://github.com/Noooste/garage-ui/issues) to push something up the list.
|
||||
|
||||
**Object browser**
|
||||
- [ ] Inline preview (images, PDF, video, text/markdown, code)
|
||||
- [ ] Resumable multipart uploads with pause/resume
|
||||
- [ ] Folder uploads preserving prefix structure
|
||||
- [ ] Bulk actions (delete, copy prefix, download prefix as zip)
|
||||
- [ ] Command palette (Cmd-K) and keyboard navigation
|
||||
|
||||
**Sharing**
|
||||
- [ ] Presigned download links with expiry + QR code
|
||||
- [ ] Presigned upload drop-zones ("send me a file" pages)
|
||||
|
||||
**Buckets**
|
||||
- [ ] Bucket alias manager (global vs. user-scoped)
|
||||
- [ ] Quota editor with live usage bar
|
||||
- [ ] Lifecycle editor (expiration + abort-multipart)
|
||||
- [ ] CORS editor with built-in test request
|
||||
- [ ] Website config (index/error docs) with live link
|
||||
- [ ] Per-bucket usage graph over time
|
||||
|
||||
**Access keys**
|
||||
- [ ] Permission matrix view (keys x 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
|
||||
- [ ] Worker/repair panel (trigger scrub, repair, rebalance)
|
||||
|
||||
**Observability**
|
||||
- [ ] Dashboard with dedup/compression savings
|
||||
- [ ] Metrics explorer pulling from Garage `/metrics`
|
||||
- [ ] Admin audit log
|
||||
|
||||
**Polish**
|
||||
- [ ] i18n (FR/EN)
|
||||
- [ ] Mobile-friendly object browser
|
||||
- [ ] First-run onboarding wizard
|
||||
- [ ] GitOps export (layout + buckets + keys as YAML)
|
||||
- [x] **Fine-grained access control**: OIDC teams with per-bucket-prefix permissions, see [docs/access-control.md](docs/access-control.md)
|
||||
- [x] **Object search**: recursive substring search across a bucket
|
||||
- [x] **Bucket quotas**: size and object count limits from bucket settings
|
||||
- [x] **Zero-config startup**: run straight from `garage.toml`, log in with the admin token
|
||||
- [x] **Broad compatibility**: Garage v1 through latest, IPv6-only networks, secrets from files
|
||||
- [X] **Inline object preview**: images, video, PDF, and text without downloading ([#60](https://github.com/Noooste/garage-ui/issues/60))
|
||||
- [ ] **Presigned share links**: time-limited download links from the object browser
|
||||
- [ ] **Resumable uploads**: multipart uploads that survive a dropped connection
|
||||
- [ ] **Visual layout editor**: staged vs. applied diff before committing layout changes
|
||||
- [ ] **Admin audit log**: who changed what, building on access control
|
||||
- [ ] **Table and detail polish**: sortable columns, clearer node details ([#36](https://github.com/Noooste/garage-ui/issues/36), [#37](https://github.com/Noooste/garage-ui/issues/37))
|
||||
|
||||
## License
|
||||
|
||||
@@ -285,4 +263,8 @@ 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/)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">Made with ❤️ in France 🇫🇷</p>
|
||||
@@ -0,0 +1,209 @@
|
||||
// Command seed bulk-loads a Garage/S3 bucket with millions of small objects
|
||||
// for testing the object browser and search features.
|
||||
//
|
||||
// Keys are a deterministic function of a global index, so a run is resumable:
|
||||
// re-run with -start=<last index> and any re-uploaded key simply overwrites.
|
||||
// Layout: pets/<species>/<breed>/<name>-<NNNNNN>.dat
|
||||
//
|
||||
// cd backend && go run ./cmd/seed # full 3,000,000 objects
|
||||
// go run ./cmd/seed -count 10000 # quick smoke test
|
||||
// go run ./cmd/seed -start 1500000 # resume from index 1.5M
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// species -> breeds. Real words so keys are searchable by substring
|
||||
// (e.g. "golden", "retriever", "siamese"). Flattened into speciesBreed at init.
|
||||
var taxonomy = []struct {
|
||||
species string
|
||||
breeds []string
|
||||
}{
|
||||
{"dogs", []string{"labrador", "golden-retriever", "german-shepherd", "bulldog", "poodle", "beagle", "rottweiler", "dachshund", "husky", "chihuahua"}},
|
||||
{"cats", []string{"siamese", "persian", "maine-coon", "bengal", "ragdoll", "sphynx", "british-shorthair", "abyssinian"}},
|
||||
{"birds", []string{"parrot", "canary", "cockatiel", "budgie", "finch", "macaw", "lovebird"}},
|
||||
{"fish", []string{"goldfish", "guppy", "betta", "angelfish", "tetra", "molly"}},
|
||||
{"rabbits", []string{"holland-lop", "netherland-dwarf", "rex", "lionhead", "flemish-giant"}},
|
||||
{"hamsters", []string{"syrian", "dwarf-campbell", "roborovski", "chinese"}},
|
||||
{"reptiles", []string{"leopard-gecko", "iguana", "bearded-dragon", "corn-snake", "box-turtle"}},
|
||||
{"horses", []string{"arabian", "thoroughbred", "mustang", "clydesdale", "appaloosa"}},
|
||||
{"guinea-pigs", []string{"american", "abyssinian", "peruvian", "silkie"}},
|
||||
{"ferrets", []string{"sable", "albino", "cinnamon", "chocolate"}},
|
||||
}
|
||||
|
||||
// petNames are the leaf file names. ~50 common pet names.
|
||||
var petNames = []string{
|
||||
"buddy", "luna", "max", "bella", "charlie", "lucy", "cooper", "daisy", "rocky", "molly",
|
||||
"bailey", "sadie", "duke", "maggie", "bear", "sophie", "tucker", "chloe", "oliver", "lola",
|
||||
"jack", "zoe", "toby", "ruby", "teddy", "rosie", "milo", "gracie", "oscar", "coco",
|
||||
"leo", "penny", "rex", "willow", "sam", "honey", "gus", "ginger", "murphy", "olive",
|
||||
"jasper", "hazel", "finn", "ivy", "louie", "pepper", "ziggy", "nala", "apollo", "cleo",
|
||||
}
|
||||
|
||||
type speciesBreedPair struct{ species, breed string }
|
||||
|
||||
var speciesBreed []speciesBreedPair
|
||||
|
||||
func init() {
|
||||
for _, t := range taxonomy {
|
||||
for _, b := range t.breeds {
|
||||
speciesBreed = append(speciesBreed, speciesBreedPair{t.species, b})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keyFor maps a global index to a unique object key with even folder fill.
|
||||
// folderIdx selects the species/breed folder; within selects (name, suffix)
|
||||
// inside that folder. The mapping is a bijection, so keys never collide.
|
||||
func keyFor(i int64) string {
|
||||
c := int64(len(speciesBreed))
|
||||
n := int64(len(petNames))
|
||||
folderIdx := i % c
|
||||
within := i / c
|
||||
name := petNames[within%n]
|
||||
suffix := within / n
|
||||
sb := speciesBreed[folderIdx]
|
||||
return fmt.Sprintf("pets/%s/%s/%s-%06d.dat", sb.species, sb.breed, name, suffix)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
endpoint = flag.String("endpoint", "localhost:3900", "S3 endpoint host:port")
|
||||
bucket = flag.String("bucket", "test", "target bucket")
|
||||
region = flag.String("region", "garage", "S3 region")
|
||||
accessKey = flag.String("access-key", "GK4b706791e6efb7bc00a99c69", "S3 access key")
|
||||
secretKey = flag.String("secret-key", "cdb665539872887e4fca34841ad2ebd79cda7af2302b500097262ec030123b14", "S3 secret key")
|
||||
count = flag.Int64("count", 3_000_000, "total dataset size (upper index, exclusive)")
|
||||
start = flag.Int64("start", 0, "start index (resume point)")
|
||||
size = flag.Int("size", 4096, "bytes per object")
|
||||
concurrency = flag.Int("concurrency", 64, "concurrent upload workers")
|
||||
secure = flag.Bool("secure", false, "use HTTPS")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
client, err := minio.New(*endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(*accessKey, *secretKey, ""),
|
||||
Secure: *secure,
|
||||
Region: *region,
|
||||
BucketLookup: minio.BucketLookupPath, // Garage needs path-style
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("client init: %v", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
total := *count - *start
|
||||
if total <= 0 {
|
||||
log.Fatalf("nothing to do: start=%d >= count=%d", *start, *count)
|
||||
}
|
||||
log.Printf("seeding bucket %q: indices [%d,%d) = %d objects of %d bytes, concurrency=%d, folders=%d",
|
||||
*bucket, *start, *count, total, *size, *concurrency, len(speciesBreed))
|
||||
|
||||
var (
|
||||
cursor = *start
|
||||
done int64
|
||||
errCount int64
|
||||
wg sync.WaitGroup
|
||||
startTime = time.Now()
|
||||
)
|
||||
|
||||
// Progress reporter.
|
||||
reportDone := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
var last int64
|
||||
lastT := startTime
|
||||
for {
|
||||
select {
|
||||
case <-reportDone:
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
d := atomic.LoadInt64(&done)
|
||||
cur := atomic.LoadInt64(&cursor)
|
||||
instRate := float64(d-last) / now.Sub(lastT).Seconds()
|
||||
last, lastT = d, now
|
||||
var eta time.Duration
|
||||
if instRate > 0 {
|
||||
eta = time.Duration(float64(total-d)/instRate) * time.Second
|
||||
}
|
||||
log.Printf("progress: %d/%d (%.1f%%) | %.0f obj/s | errors=%d | next-index=%d | eta=%s",
|
||||
d, total, 100*float64(d)/float64(total), instRate,
|
||||
atomic.LoadInt64(&errCount), cur, eta.Round(time.Second))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
opts := minio.PutObjectOptions{ContentType: "application/octet-stream", DisableMultipart: true}
|
||||
|
||||
for w := 0; w < *concurrency; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
// One random, incompressible buffer per worker (Garage compresses
|
||||
// per-object, so reuse is fine and avoids per-object allocation).
|
||||
buf := make([]byte, *size)
|
||||
r := rand.New(rand.NewSource(int64(1000 + worker)))
|
||||
for j := range buf {
|
||||
buf[j] = byte(r.Intn(256))
|
||||
}
|
||||
|
||||
for {
|
||||
idx := atomic.AddInt64(&cursor, 1) - 1
|
||||
if idx >= *count {
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
key := keyFor(idx)
|
||||
var putErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
_, putErr = client.PutObject(ctx, *bucket, key, bytes.NewReader(buf), int64(*size), opts)
|
||||
if putErr == nil || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
||||
}
|
||||
if putErr != nil {
|
||||
if n := atomic.AddInt64(&errCount, 1); n <= 10 {
|
||||
log.Printf("put %q failed: %v", key, putErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&done, 1)
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(reportDone)
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
d := atomic.LoadInt64(&done)
|
||||
log.Printf("DONE: uploaded %d/%d objects in %s (%.0f obj/s), errors=%d",
|
||||
d, total, elapsed.Round(time.Second), float64(d)/elapsed.Seconds(), atomic.LoadInt64(&errCount))
|
||||
if ctx.Err() != nil {
|
||||
log.Printf("interrupted; resume with -start=%d", atomic.LoadInt64(&cursor))
|
||||
}
|
||||
if atomic.LoadInt64(&errCount) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,12 @@ type Service struct {
|
||||
|
||||
// UserInfo represents authenticated user information
|
||||
type UserInfo struct {
|
||||
Username string
|
||||
Email string
|
||||
Name string
|
||||
Roles []string
|
||||
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
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
@@ -179,6 +181,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -219,6 +225,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -252,6 +262,33 @@ 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.
|
||||
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
||||
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
|
||||
@@ -322,8 +359,21 @@ func extractRoles(claims map[string]interface{}, path string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractStringArray converts an interface{} to []string if possible
|
||||
// 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
|
||||
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
|
||||
@@ -375,9 +425,11 @@ func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
Username: claims.Username,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
Roles: claims.Roles,
|
||||
Username: claims.Username,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
Roles: claims.Roles,
|
||||
Teams: claims.Teams,
|
||||
AuthMethod: claims.AuthMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -564,8 +564,11 @@ func TestExtractRolesFromAccessToken_IntermediateNodeNotMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
|
||||
// Final value is a plain string, not an array — extractStringArray returns nil.
|
||||
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.
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"roles": "admin",
|
||||
})
|
||||
@@ -574,8 +577,25 @@ func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
|
||||
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 non-array roles, got %v", got)
|
||||
t.Errorf("expected nil for empty scalar role, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,12 @@ type StateData struct {
|
||||
}
|
||||
|
||||
type SessionClaims struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Roles []string `json:"roles"`
|
||||
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"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -160,10 +162,12 @@ 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,
|
||||
Username: userInfo.Username,
|
||||
Email: userInfo.Email,
|
||||
Name: userInfo.Name,
|
||||
Roles: userInfo.Roles,
|
||||
Teams: userInfo.Teams,
|
||||
AuthMethod: userInfo.AuthMethod,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PreviewTokenLocalsKey is the fiber Locals key the auth middleware sets
|
||||
// after validating a preview token. The authz middleware reads it to
|
||||
// authorize the request without a subject.
|
||||
const PreviewTokenLocalsKey = "previewTokenClaims"
|
||||
|
||||
// PreviewClaims identify the single object a preview token can read.
|
||||
type PreviewClaims struct {
|
||||
Bucket string `json:"b"`
|
||||
Key string `json:"k"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// previewSecret derives the HMAC key from the JWT signing key. A configured
|
||||
// session key therefore keeps preview URLs valid across restarts, and a
|
||||
// generated key invalidates them on restart, which the frontend recovers
|
||||
// from by minting a fresh URL.
|
||||
func (j *JWTService) previewSecret() []byte {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
sum := sha256.Sum256(append([]byte("garage-ui-preview-token:"), j.privateKey...))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// MintPreviewToken signs a token granting read access to one exact object
|
||||
// until the TTL elapses.
|
||||
func (a *Service) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
token, err := mintPreviewToken(a.jwtService.previewSecret(), bucket, key, expiresAt)
|
||||
return token, expiresAt, err
|
||||
}
|
||||
|
||||
// ValidatePreviewToken checks the signature, expiry, and exact object match.
|
||||
func (a *Service) ValidatePreviewToken(token, bucket, key string) error {
|
||||
return verifyPreviewToken(a.jwtService.previewSecret(), token, bucket, key, time.Now())
|
||||
}
|
||||
|
||||
func mintPreviewToken(secret []byte, bucket, key string, expiresAt time.Time) (string, error) {
|
||||
payload, err := json.Marshal(PreviewClaims{Bucket: bucket, Key: key, ExpiresAt: expiresAt.Unix()})
|
||||
// Defensive and unreachable: marshaling a struct of two strings and an int64
|
||||
// cannot fail, so this branch stays uncovered by design.
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode preview claims: %w", err)
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return encoded + "." + signPreview(secret, encoded), nil
|
||||
}
|
||||
|
||||
func verifyPreviewToken(secret []byte, token, bucket, key string, now time.Time) error {
|
||||
encoded, sig, ok := strings.Cut(token, ".")
|
||||
if !ok {
|
||||
return fmt.Errorf("malformed preview token")
|
||||
}
|
||||
if !hmac.Equal([]byte(sig), []byte(signPreview(secret, encoded))) {
|
||||
return fmt.Errorf("invalid preview token signature")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("malformed preview token payload")
|
||||
}
|
||||
var claims PreviewClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return fmt.Errorf("malformed preview token claims")
|
||||
}
|
||||
if now.Unix() > claims.ExpiresAt {
|
||||
return fmt.Errorf("preview token expired")
|
||||
}
|
||||
if claims.Bucket != bucket || claims.Key != key {
|
||||
return fmt.Errorf("preview token does not match the requested object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signPreview(secret []byte, encoded string) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(encoded))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newPreviewTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
jwtSvc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
return &Service{jwtService: jwtSvc}
|
||||
}
|
||||
|
||||
func TestPreviewToken_RoundTrip(t *testing.T) {
|
||||
svc := newPreviewTestService(t)
|
||||
token, expiresAt, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
if remaining := time.Until(expiresAt); remaining < 59*time.Minute || remaining > time.Hour {
|
||||
t.Errorf("expiresAt %v is not about an hour away", expiresAt)
|
||||
}
|
||||
if err := svc.ValidatePreviewToken(token, "b1", "dir/clip.mp4"); err != nil {
|
||||
t.Errorf("ValidatePreviewToken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewToken_Expired(t *testing.T) {
|
||||
svc := newPreviewTestService(t)
|
||||
token, _, err := svc.MintPreviewToken("b1", "k", -time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
|
||||
t.Error("expected expired token to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewToken_WrongObject(t *testing.T) {
|
||||
svc := newPreviewTestService(t)
|
||||
token, _, err := svc.MintPreviewToken("b1", "k1", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
if err := svc.ValidatePreviewToken(token, "b2", "k1"); err == nil {
|
||||
t.Error("expected wrong bucket to be rejected")
|
||||
}
|
||||
if err := svc.ValidatePreviewToken(token, "b1", "k2"); err == nil {
|
||||
t.Error("expected wrong key to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewToken_Tampered(t *testing.T) {
|
||||
svc := newPreviewTestService(t)
|
||||
token, _, err := svc.MintPreviewToken("b1", "k", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
payload, sig, _ := strings.Cut(token, ".")
|
||||
flipped := "A" + payload[1:]
|
||||
if flipped == payload {
|
||||
flipped = "B" + payload[1:]
|
||||
}
|
||||
if err := svc.ValidatePreviewToken(flipped+"."+sig, "b1", "k"); err == nil {
|
||||
t.Error("expected tampered payload to be rejected")
|
||||
}
|
||||
if err := svc.ValidatePreviewToken(payload+".AAAA", "b1", "k"); err == nil {
|
||||
t.Error("expected tampered signature to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewToken_Malformed(t *testing.T) {
|
||||
svc := newPreviewTestService(t)
|
||||
for _, tok := range []string{"", "nodot", "a.b.c", "!!!.???", "bm90anNvbg.sig"} {
|
||||
if err := svc.ValidatePreviewToken(tok, "b1", "k"); err == nil {
|
||||
t.Errorf("expected malformed token %q to be rejected", tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewToken_DifferentServicesRejectEachOther(t *testing.T) {
|
||||
a := newPreviewTestService(t)
|
||||
b := newPreviewTestService(t)
|
||||
token, _, err := a.MintPreviewToken("b1", "k", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
if err := b.ValidatePreviewToken(token, "b1", "k"); err == nil {
|
||||
t.Error("expected a token from another key to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewToken_ValidSignatureMalformedBase64Payload signs a payload that
|
||||
// is not valid RawURLEncoding, so the signature check passes but the base64
|
||||
// decode fails. This exercises the decode error branch in verifyPreviewToken.
|
||||
func TestPreviewToken_ValidSignatureMalformedBase64Payload(t *testing.T) {
|
||||
svc := newPreviewTestService(t)
|
||||
secret := svc.jwtService.previewSecret()
|
||||
enc := "!!not-base64!!"
|
||||
token := enc + "." + signPreview(secret, enc)
|
||||
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
|
||||
t.Error("expected a validly signed but non-base64 payload to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewToken_ValidSignatureNonJSONPayload signs a valid base64 payload
|
||||
// whose bytes are not JSON, so the signature and decode both pass but the
|
||||
// unmarshal fails. This exercises the JSON error branch in verifyPreviewToken.
|
||||
func TestPreviewToken_ValidSignatureNonJSONPayload(t *testing.T) {
|
||||
svc := newPreviewTestService(t)
|
||||
secret := svc.jwtService.previewSecret()
|
||||
enc := base64.RawURLEncoding.EncodeToString([]byte("not json"))
|
||||
token := enc + "." + signPreview(secret, enc)
|
||||
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
|
||||
t.Error("expected a validly signed but non-JSON payload to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// SubjectLocalsKey is the fiber.Ctx.Locals key carrying the resolved Subject.
|
||||
const SubjectLocalsKey = "authzSubject"
|
||||
|
||||
// Middleware wires the policy into Fiber. When the policy is disabled
|
||||
// (access_control absent) every handler is a passthrough no-op.
|
||||
type Middleware struct {
|
||||
enabled bool
|
||||
resolver TeamResolver
|
||||
authorizer Authorizer
|
||||
}
|
||||
|
||||
func NewMiddleware(policy *Policy, resolver TeamResolver, authorizer Authorizer) *Middleware {
|
||||
return &Middleware{enabled: policy.Enabled, resolver: resolver, authorizer: authorizer}
|
||||
}
|
||||
|
||||
// Enabled reports whether access control is active.
|
||||
func (m *Middleware) Enabled() bool { return m.enabled }
|
||||
|
||||
// ResolveSubject computes the request's Subject once, right after
|
||||
// authentication. Handlers and the capabilities endpoint read the same
|
||||
// struct, so enforcement and UI can never disagree.
|
||||
func (m *Middleware) ResolveSubject() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
if !m.enabled {
|
||||
return c.Next()
|
||||
}
|
||||
userInfo, ok := c.Locals("userInfo").(*auth.UserInfo)
|
||||
if !ok || userInfo == nil {
|
||||
return c.Next() // no identity (auth disabled); Require will deny
|
||||
}
|
||||
c.Locals(SubjectLocalsKey, m.resolver.Resolve(userInfo))
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// SubjectFrom returns the Subject resolved for this request, if any.
|
||||
func SubjectFrom(c fiber.Ctx) (Subject, bool) {
|
||||
subj, ok := c.Locals(SubjectLocalsKey).(Subject)
|
||||
return subj, ok
|
||||
}
|
||||
|
||||
// ScopeResolver extracts the target Resource from the request.
|
||||
type ScopeResolver func(c fiber.Ctx) Resource
|
||||
|
||||
// ScopeNone is for global permissions and unscoped list endpoints.
|
||||
func ScopeNone(fiber.Ctx) Resource { return Resource{} }
|
||||
|
||||
// BucketFromParam reads the bucket name from a URL parameter.
|
||||
func BucketFromParam(param string) ScopeResolver {
|
||||
return func(c fiber.Ctx) Resource {
|
||||
return Resource{Bucket: c.Params(param)}
|
||||
}
|
||||
}
|
||||
|
||||
// BucketFromBody reads the bucket name from a JSON body {"name": "..."}.
|
||||
// Fiber buffers the body, so the handler can bind it again afterwards.
|
||||
func BucketFromBody() ScopeResolver {
|
||||
return func(c fiber.Ctx) Resource {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return Resource{}
|
||||
}
|
||||
return Resource{Bucket: req.Name}
|
||||
}
|
||||
}
|
||||
|
||||
// Require gates a route on the caller holding ALL of perms for the resolved
|
||||
// resource. One structured decision log line is emitted per check: denies at
|
||||
// warn, allows at debug.
|
||||
//
|
||||
// The closure's function name is the marker VerifyRouteCoverage looks for.
|
||||
// Do not wrap it in another anonymous function.
|
||||
func (m *Middleware) Require(scope ScopeResolver, perms ...string) fiber.Handler {
|
||||
if len(perms) == 0 {
|
||||
// A no-perms Require would silently no-op (allow everything) yet still
|
||||
// satisfy VerifyRouteCoverage, defeating the fail-closed guarantee.
|
||||
panic("authz.Require: at least one permission required")
|
||||
}
|
||||
for _, p := range perms {
|
||||
if !IsValidPermission(p) {
|
||||
panic(fmt.Sprintf("authz.Require: unknown permission %q", p)) // programmer error, fail at wiring time
|
||||
}
|
||||
}
|
||||
return func(c fiber.Ctx) error {
|
||||
if !m.enabled {
|
||||
return c.Next()
|
||||
}
|
||||
// A validated preview token authorizes exactly one thing: object.read
|
||||
// on the object it names. The auth middleware set these claims after
|
||||
// verifying the signature and the bucket and key match this request.
|
||||
if claims, ok := c.Locals(auth.PreviewTokenLocalsKey).(*auth.PreviewClaims); ok && claims != nil {
|
||||
if len(perms) == 1 && perms[0] == PermObjectRead && scope(c).Bucket == claims.Bucket {
|
||||
logDecision(c, "preview-token", PermObjectRead, claims.Bucket, true, "preview_token")
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
subj, ok := SubjectFrom(c)
|
||||
if !ok {
|
||||
logDecision(c, "", strings.Join(perms, ","), "", false, "no_subject")
|
||||
return forbidden(c, perms[0])
|
||||
}
|
||||
res := scope(c)
|
||||
for _, perm := range perms {
|
||||
d := m.authorizer.Decide(subj, perm, res)
|
||||
logDecision(c, subj.ID, perm, res.Bucket, d.Allow, d.Reason)
|
||||
if !d.Allow {
|
||||
return forbidden(c, perm)
|
||||
}
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func forbidden(c fiber.Ctx, perm string) error {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Missing permission: "+perm),
|
||||
)
|
||||
}
|
||||
|
||||
func logDecision(c fiber.Ctx, subject, action, resource string, allow bool, reason string) {
|
||||
l := logpkg.FromCtx(c.Context())
|
||||
evt := l.Debug()
|
||||
if !allow {
|
||||
evt = l.Warn()
|
||||
}
|
||||
decision := "allow"
|
||||
if !allow {
|
||||
decision = "deny"
|
||||
}
|
||||
evt.Str("subject", subject).
|
||||
Str("action", action).
|
||||
Str("resource", resource).
|
||||
Str("decision", decision).
|
||||
Str("reason", reason).
|
||||
Msg("authz_decision")
|
||||
}
|
||||
|
||||
// coverageExemptPaths are /api/v1 routes that intentionally carry no Require:
|
||||
// health is unauthenticated, capabilities is the frontend's fail-closed
|
||||
// source and returns only the caller's own permissions.
|
||||
var coverageExemptPaths = map[string]struct{}{
|
||||
"/api/v1/health": {},
|
||||
"/api/v1/capabilities": {},
|
||||
}
|
||||
|
||||
// VerifyRouteCoverage walks the app's route table and errors if any /api/v1
|
||||
// route lacks a Require handler. Called at startup (and from tests): a new
|
||||
// endpoint registered without declaring its permission prevents boot instead
|
||||
// of silently failing open.
|
||||
//
|
||||
// .Use()-registered routes need special handling. Group-level middleware
|
||||
// (api.Use(handler) on the "/api/v1" group) produces synthetic per-method
|
||||
// bookkeeping entries at exactly the bare prefix. Those aren't endpoints and
|
||||
// are exempt. But a use-route at any deeper path (api.Use("/sneaky",
|
||||
// terminalHandler)) IS a reachable endpoint and gets the same fail-closed
|
||||
// treatment as normal routes.
|
||||
func VerifyRouteCoverage(app *fiber.App) error {
|
||||
// fiber.Route doesn't export whether a route was .Use()-registered, so
|
||||
// classify by diffing GetRoutes() (everything) against GetRoutes(true)
|
||||
// (use-routes filtered out): entries unmatched in the filtered multiset
|
||||
// are use-registered.
|
||||
nonUse := map[string]int{}
|
||||
for _, r := range app.GetRoutes(true) {
|
||||
nonUse[routeKey(r)]++
|
||||
}
|
||||
|
||||
var naked []string
|
||||
for _, route := range app.GetRoutes() {
|
||||
// Consume the multiset for every route, before any skip, so
|
||||
// classification stays consistent across the whole table.
|
||||
isUse := true
|
||||
if k := routeKey(route); nonUse[k] > 0 {
|
||||
nonUse[k]--
|
||||
isUse = false
|
||||
}
|
||||
if !strings.HasPrefix(route.Path, "/api/v1") {
|
||||
continue
|
||||
}
|
||||
if isUse && route.Path == "/api/v1" {
|
||||
continue // group-middleware bookkeeping at the bare prefix
|
||||
}
|
||||
if _, exempt := coverageExemptPaths[route.Path]; exempt {
|
||||
continue
|
||||
}
|
||||
if route.Method == fiber.MethodHead && hasRequireForPath(app, fiber.MethodGet, route.Path) {
|
||||
continue // Fiber auto-registers HEAD mirroring GET
|
||||
}
|
||||
if !routeHasRequire(route.Handlers) {
|
||||
naked = append(naked, route.Method+" "+route.Path)
|
||||
}
|
||||
}
|
||||
if len(naked) > 0 {
|
||||
return fmt.Errorf("authz: routes without Require permission declaration: %s", strings.Join(naked, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// routeKey identifies a route for the use-route diff. Method+Path+handler
|
||||
// count is robust enough: two routes sharing all three are interchangeable
|
||||
// for coverage purposes, and the multiset keeps counts honest.
|
||||
func routeKey(r fiber.Route) string {
|
||||
return r.Method + " " + r.Path + " " + strconv.Itoa(len(r.Handlers))
|
||||
}
|
||||
|
||||
func hasRequireForPath(app *fiber.App, method, path string) bool {
|
||||
for _, route := range app.GetRoutes(true) {
|
||||
if route.Method == method && route.Path == path {
|
||||
return routeHasRequire(route.Handlers)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func routeHasRequire(handlers []fiber.Handler) bool {
|
||||
for _, h := range handlers {
|
||||
fn := runtime.FuncForPC(fiberHandlerPC(h))
|
||||
if fn != nil && strings.Contains(fn.Name(), "authz.(*Middleware).Require") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fiberHandlerPC(h fiber.Handler) uintptr {
|
||||
return reflect.ValueOf(h).Pointer()
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func middlewareFixture(t *testing.T) *Middleware {
|
||||
t.Helper()
|
||||
policy, err := CompilePolicy(&config.AccessControlConfig{
|
||||
Teams: []config.TeamConfig{{
|
||||
Name: "backend",
|
||||
ClaimValues: []string{"g-backend"},
|
||||
Bindings: []config.BindingConfig{{
|
||||
BucketPrefixes: []string{"backend-"},
|
||||
Permissions: []string{"bucket.read", "bucket.create", "object.read"},
|
||||
}},
|
||||
ClusterPermissions: []string{"cluster.status"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewMiddleware(policy, NewTeamResolver(policy, []string{"garage-admin"}), NewAuthorizer())
|
||||
}
|
||||
|
||||
func newTestApp(m *Middleware, userInfo *auth.UserInfo) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware
|
||||
if userInfo != nil {
|
||||
c.Locals("userInfo", userInfo)
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(m.ResolveSubject())
|
||||
app.Get("/api/v1/buckets/:name", m.Require(BucketFromParam("name"), PermBucketRead), func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
app.Post("/api/v1/buckets", m.Require(BucketFromBody(), PermBucketCreate), func(c fiber.Ctx) error {
|
||||
return c.SendString("created")
|
||||
})
|
||||
app.Get("/api/v1/cluster/status", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func doReq(t *testing.T, app *fiber.App, method, path, body string) int {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func TestRequireAllowsMatchingTeam(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
|
||||
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 200 {
|
||||
t.Errorf("matching bucket: status %d, want 200", code)
|
||||
}
|
||||
if code := doReq(t, app, "GET", "/api/v1/cluster/status", ""); code != 200 {
|
||||
t.Errorf("cluster.status: status %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDeniesOutOfScope(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
|
||||
if code := doReq(t, app, "GET", "/api/v1/buckets/data-warehouse", ""); code != 403 {
|
||||
t.Errorf("non-matching bucket: status %d, want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBucketFromBody(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
|
||||
if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"backend-new"}`); code != 200 {
|
||||
t.Errorf("create with matching prefix: status %d, want 200", code)
|
||||
}
|
||||
if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"other-new"}`); code != 403 {
|
||||
t.Errorf("create with foreign prefix: status %d, want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDefaultDenyZeroTeamUser(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
app := newTestApp(m, &auth.UserInfo{Email: "z@x", AuthMethod: "oidc"})
|
||||
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 {
|
||||
t.Errorf("zero-team user: status %d, want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePassthroughWhenDisabled(t *testing.T) {
|
||||
policy, _ := CompilePolicy(nil)
|
||||
m := NewMiddleware(policy, NewTeamResolver(policy, nil), NewAuthorizer())
|
||||
// No userInfo at all. Disabled access control must not require a subject.
|
||||
app := newTestApp(m, nil)
|
||||
if code := doReq(t, app, "GET", "/api/v1/buckets/anything", ""); code != 200 {
|
||||
t.Errorf("disabled: status %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareEnabledReflectsPolicy(t *testing.T) {
|
||||
if !middlewareFixture(t).Enabled() {
|
||||
t.Error("Enabled() = false for a configured policy, want true")
|
||||
}
|
||||
disabled, _ := CompilePolicy(nil)
|
||||
m := NewMiddleware(disabled, NewTeamResolver(disabled, nil), NewAuthorizer())
|
||||
if m.Enabled() {
|
||||
t.Error("Enabled() = true for a nil (disabled) policy, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSubjectWithoutUserInfoDenies(t *testing.T) {
|
||||
// Enabled middleware, but auth set no userInfo local: ResolveSubject leaves
|
||||
// no subject, and Require then denies for want of one.
|
||||
m := middlewareFixture(t)
|
||||
app := newTestApp(m, nil)
|
||||
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 {
|
||||
t.Errorf("enabled + no userInfo: status %d, want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBucketFromBodyMalformedBody(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
|
||||
// Malformed JSON: BucketFromBody's bind fails and returns an empty resource.
|
||||
// An empty bucket is an unscoped check, so a team holding bucket.create in
|
||||
// any binding is allowed (this is what proves the resource came back empty:
|
||||
// a non-empty foreign bucket name would be denied instead).
|
||||
if code := doReq(t, app, "POST", "/api/v1/buckets", "{not-json"); code != 200 {
|
||||
t.Errorf("malformed body: status %d, want 200 (empty resource, any_binding)", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireUnknownPermissionPanics(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("Require with an unknown permission: want panic, got none")
|
||||
}
|
||||
if msg, ok := r.(string); !ok || !strings.Contains(msg, "unknown permission") {
|
||||
t.Errorf("panic value = %v, want message containing %q", r, "unknown permission")
|
||||
}
|
||||
}()
|
||||
m.Require(ScopeNone, "bogus.permission")
|
||||
}
|
||||
|
||||
func TestVerifyRouteCoverage(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
|
||||
covered := fiber.New()
|
||||
covered.Get("/api/v1/capabilities", func(c fiber.Ctx) error { return nil }) // exempt
|
||||
covered.Get("/api/v1/health", func(c fiber.Ctx) error { return nil }) // exempt
|
||||
covered.Get("/api/v1/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil })
|
||||
covered.Get("/other", func(c fiber.Ctx) error { return nil }) // outside /api/v1
|
||||
if err := VerifyRouteCoverage(covered); err != nil {
|
||||
t.Errorf("covered app: %v, want nil", err)
|
||||
}
|
||||
|
||||
uncovered := fiber.New()
|
||||
uncovered.Get("/api/v1/naked", func(c fiber.Ctx) error { return nil })
|
||||
err := VerifyRouteCoverage(uncovered)
|
||||
if err == nil {
|
||||
t.Fatal("uncovered app: want error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/api/v1/naked") {
|
||||
t.Errorf("error should name the naked route: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRouteCoverage_GroupUseBookkeepingExempt(t *testing.T) {
|
||||
// Group-level .Use() middleware produces synthetic per-method entries at
|
||||
// exactly the bare group prefix; those aren't endpoints and must not trip
|
||||
// the coverage check as long as the real routes carry Require.
|
||||
m := middlewareFixture(t)
|
||||
app := fiber.New()
|
||||
api := app.Group("/api/v1")
|
||||
api.Use(func(c fiber.Ctx) error { return c.Next() }) // stand-in for AuthMiddleware
|
||||
api.Use(m.ResolveSubject())
|
||||
api.Get("/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil })
|
||||
if err := VerifyRouteCoverage(app); err != nil {
|
||||
t.Errorf("group-level Use at bare prefix should be exempt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireZeroPermissionsPanics(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("Require with zero perms: want panic, got none")
|
||||
}
|
||||
msg, ok := r.(string)
|
||||
if !ok || !strings.Contains(msg, "at least one permission required") {
|
||||
t.Errorf("panic value = %v, want message containing %q", r, "at least one permission required")
|
||||
}
|
||||
}()
|
||||
m.Require(ScopeNone)
|
||||
}
|
||||
|
||||
func newPreviewClaimsApp(m *Middleware, claims *auth.PreviewClaims) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware validating a preview token
|
||||
if claims != nil {
|
||||
c.Locals(auth.PreviewTokenLocalsKey, claims)
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(m.ResolveSubject())
|
||||
app.Get("/api/v1/buckets/:bucket/objects/*", m.Require(BucketFromParam("bucket"), PermObjectRead), func(c fiber.Ctx) error {
|
||||
return c.SendString("bytes")
|
||||
})
|
||||
app.Delete("/api/v1/buckets/:bucket/objects/*", m.Require(BucketFromParam("bucket"), PermObjectDelete), func(c fiber.Ctx) error {
|
||||
return c.SendString("deleted")
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func TestRequirePreviewTokenBypass(t *testing.T) {
|
||||
m := middlewareFixture(t)
|
||||
|
||||
t.Run("matching bucket allows object read without a subject", func(t *testing.T) {
|
||||
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "any-bucket", Key: "k"})
|
||||
if code := doReq(t, app, "GET", "/api/v1/buckets/any-bucket/objects/k", ""); code != 200 {
|
||||
t.Errorf("status = %d, want 200", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bucket mismatch denies", func(t *testing.T) {
|
||||
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "bucket-a", Key: "k"})
|
||||
if code := doReq(t, app, "GET", "/api/v1/buckets/bucket-b/objects/k", ""); code != 403 {
|
||||
t.Errorf("status = %d, want 403", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other permissions stay denied", func(t *testing.T) {
|
||||
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "any-bucket", Key: "k"})
|
||||
if code := doReq(t, app, "DELETE", "/api/v1/buckets/any-bucket/objects/k", ""); code != 403 {
|
||||
t.Errorf("status = %d, want 403", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no claims still requires a subject", func(t *testing.T) {
|
||||
app := newPreviewClaimsApp(m, nil)
|
||||
if code := doReq(t, app, "GET", "/api/v1/buckets/any-bucket/objects/k", ""); code != 403 {
|
||||
t.Errorf("status = %d, want 403", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyRouteCoverage_UseRegisteredEndpointFlagged(t *testing.T) {
|
||||
// A .Use()-registered route at a DEEPER path under /api/v1 is a reachable
|
||||
// endpoint (Fiber runs it for every method with that prefix); it must get
|
||||
// the same fail-closed treatment as a normal route.
|
||||
app := fiber.New()
|
||||
api := app.Group("/api/v1")
|
||||
api.Use(func(c fiber.Ctx) error { return c.Next() }) // bare-prefix middleware stays exempt
|
||||
api.Use("/sneaky", func(c fiber.Ctx) error { return c.SendString("terminal") })
|
||||
err := VerifyRouteCoverage(app)
|
||||
if err == nil {
|
||||
t.Fatal("use-registered endpoint under /api/v1: want error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/api/v1/sneaky") {
|
||||
t.Errorf("error should name /api/v1/sneaky: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// ServerConfig contains server-related configuration
|
||||
@@ -48,10 +49,11 @@ type GarageConfig struct {
|
||||
|
||||
// AuthConfig contains authentication configuration
|
||||
type AuthConfig struct {
|
||||
Admin AdminAuthConfig `mapstructure:"admin"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Token TokenAuthConfig `mapstructure:"token"`
|
||||
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
|
||||
Admin AdminAuthConfig `mapstructure:"admin"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Token TokenAuthConfig `mapstructure:"token"`
|
||||
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
|
||||
MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth
|
||||
}
|
||||
|
||||
// AdminAuthConfig contains admin authentication settings
|
||||
@@ -81,6 +83,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"`
|
||||
@@ -130,6 +133,33 @@ 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)
|
||||
|
||||
@@ -168,6 +198,10 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
|
||||
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
|
||||
@@ -212,6 +246,16 @@ 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)
|
||||
@@ -248,6 +292,7 @@ func bindEnvVars() {
|
||||
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
|
||||
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
|
||||
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
|
||||
viper.BindEnv("auth.metrics_public", "GARAGE_UI_AUTH_METRICS_PUBLIC")
|
||||
|
||||
// Token auth config
|
||||
viper.BindEnv("auth.token.enabled", "GARAGE_UI_AUTH_TOKEN_ENABLED")
|
||||
@@ -265,6 +310,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")
|
||||
@@ -370,13 +416,15 @@ func (c *Config) Validate() error {
|
||||
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
||||
}
|
||||
// Every authenticated route on this service grants full admin
|
||||
// access — there is no separate authorization layer. Empty
|
||||
// admin role configuration would therefore promote every user
|
||||
// in the IdP realm to cluster admin. Require operators to opt
|
||||
// in explicitly via admin_role or admin_roles.
|
||||
if len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
|
||||
return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled: leaving them empty would grant cluster-admin access to any authenticated IdP user")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -329,6 +329,17 @@ func TestValidate(t *testing.T) {
|
||||
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) {
|
||||
@@ -490,6 +501,103 @@ func TestLoad_EnvOverridesToml(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -639,6 +747,133 @@ func TestLoad_FileBackedEnvVarMissingFileReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -659,3 +894,49 @@ func TestIsProduction(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MetricsPublic_DefaultsFalse(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, minimalValidYAML)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Auth.MetricsPublic {
|
||||
t.Errorf("Auth.MetricsPublic = true, want false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MetricsPublic_YAML(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, minimalValidYAML+`
|
||||
auth:
|
||||
metrics_public: true
|
||||
`)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.Auth.MetricsPublic {
|
||||
t.Errorf("Auth.MetricsPublic = false, want true from YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MetricsPublic_EnvOverridesYAML(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, minimalValidYAML+`
|
||||
auth:
|
||||
metrics_public: false
|
||||
`)
|
||||
t.Setenv("GARAGE_UI_AUTH_METRICS_PUBLIC", "true")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.Auth.MetricsPublic {
|
||||
t.Errorf("Auth.MetricsPublic = false, want true (env should override YAML)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,8 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
|
||||
|
||||
// Create user info object
|
||||
userInfo := &auth.UserInfo{
|
||||
Username: req.Username,
|
||||
Username: req.Username,
|
||||
AuthMethod: "admin",
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
@@ -135,7 +136,8 @@ func (h *AuthHandler) LoginToken(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
userInfo := &auth.UserInfo{
|
||||
Username: "admin-token",
|
||||
Username: "admin-token",
|
||||
AuthMethod: "token",
|
||||
}
|
||||
|
||||
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/authz"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
@@ -80,6 +81,10 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
buckets = append(buckets, bucketInfo)
|
||||
}
|
||||
|
||||
if subj, ok := authz.SubjectFrom(c); ok {
|
||||
buckets = filterBucketsForSubject(buckets, subj)
|
||||
}
|
||||
|
||||
response := models.BucketListResponse{
|
||||
Buckets: buckets,
|
||||
Count: len(buckets),
|
||||
@@ -231,6 +236,10 @@ 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))
|
||||
}
|
||||
|
||||
@@ -491,3 +500,18 @@ func (h *BucketHandler) UpdateBucketQuotas(c fiber.Ctx) 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"Noooste/garage-ui/internal/authz"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
@@ -8,20 +11,64 @@ import (
|
||||
)
|
||||
|
||||
type CapabilitiesHandler struct {
|
||||
apiVersion string
|
||||
capabilities services.Capabilities
|
||||
apiVersion string
|
||||
capabilities services.Capabilities
|
||||
accessControlEnabled bool
|
||||
}
|
||||
|
||||
func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities) *CapabilitiesHandler {
|
||||
func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities, accessControlEnabled bool) *CapabilitiesHandler {
|
||||
return &CapabilitiesHandler{
|
||||
apiVersion: apiVersion,
|
||||
capabilities: capabilities,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ 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"
|
||||
@@ -13,7 +15,7 @@ import (
|
||||
|
||||
func TestCapabilities_V2(t *testing.T) {
|
||||
app := fiber.New()
|
||||
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2())
|
||||
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false)
|
||||
app.Get("/capabilities", h.GetCapabilities)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||
@@ -30,7 +32,7 @@ func TestCapabilities_V2(t *testing.T) {
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
GarageApiVersion string `json:"garageApiVersion"`
|
||||
GarageApiVersion string `json:"garageApiVersion"`
|
||||
Features services.Capabilities `json:"features"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -50,7 +52,7 @@ func TestCapabilities_V2(t *testing.T) {
|
||||
|
||||
func TestCapabilities_V1(t *testing.T) {
|
||||
app := fiber.New()
|
||||
h := NewCapabilitiesHandler("v1", services.CapabilitiesV1())
|
||||
h := NewCapabilitiesHandler("v1", services.CapabilitiesV1(), false)
|
||||
app.Get("/capabilities", h.GetCapabilities)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||
@@ -62,7 +64,7 @@ func TestCapabilities_V1(t *testing.T) {
|
||||
|
||||
var body struct {
|
||||
Data struct {
|
||||
GarageApiVersion string `json:"garageApiVersion"`
|
||||
GarageApiVersion string `json:"garageApiVersion"`
|
||||
Features services.Capabilities `json:"features"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -76,3 +78,84 @@ func TestCapabilities_V1(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
@@ -20,13 +19,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
|
||||
@@ -68,15 +67,27 @@ func contentDispositionHeader(disposition, key string) string {
|
||||
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
|
||||
}
|
||||
|
||||
// PreviewTokenMinter mints signed single-object preview tokens.
|
||||
// auth.Service satisfies it.
|
||||
type PreviewTokenMinter interface {
|
||||
MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error)
|
||||
}
|
||||
|
||||
// previewTokenTTL is long enough that seeking mid-playback keeps working.
|
||||
// The frontend mints a fresh URL when a token expires.
|
||||
const previewTokenTTL = time.Hour
|
||||
|
||||
// ObjectHandler handles object-related HTTP requests.
|
||||
type ObjectHandler struct {
|
||||
s3Service services.S3Storage
|
||||
s3Service services.S3Storage
|
||||
previewTokens PreviewTokenMinter
|
||||
}
|
||||
|
||||
// NewObjectHandler creates a new object handler.
|
||||
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
|
||||
func NewObjectHandler(s3Service services.S3Storage, previewTokens PreviewTokenMinter) *ObjectHandler {
|
||||
return &ObjectHandler{
|
||||
s3Service: s3Service,
|
||||
s3Service: s3Service,
|
||||
previewTokens: previewTokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +100,7 @@ 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"
|
||||
@@ -109,6 +121,21 @@ 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")
|
||||
@@ -261,12 +288,8 @@ func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
|
||||
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
@@ -278,7 +301,17 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Get object from Garage
|
||||
// Range requests stream a partial body so media elements can seek.
|
||||
if rangeHeader := c.Get("Range"); rangeHeader != "" {
|
||||
return h.getObjectRange(c, bucketName, key, rangeHeader)
|
||||
}
|
||||
return h.serveFullObject(c, bucketName, key)
|
||||
}
|
||||
|
||||
// serveFullObject streams the whole object with a 200, the pre-Range behavior.
|
||||
func (h *ObjectHandler) serveFullObject(c fiber.Ctx, bucketName, key string) error {
|
||||
ctx := c.Context()
|
||||
|
||||
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
@@ -291,11 +324,12 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
// cannot run as XSS in the SPA origin when fetched inline.
|
||||
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
|
||||
c.Set("X-Content-Type-Options", "nosniff")
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
|
||||
c.Set("ETag", objectInfo.ETag)
|
||||
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
|
||||
|
||||
// The object key is attacker-controlled — build the header via the safe
|
||||
// The object key is attacker-controlled. Build the header via the safe
|
||||
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
|
||||
disposition := "inline"
|
||||
if c.Query("download") == "true" {
|
||||
@@ -303,11 +337,60 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
}
|
||||
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
|
||||
|
||||
// Stream the object body to the client without buffering the entire file
|
||||
return c.SendStreamWriter(func(w *bufio.Writer) {
|
||||
defer body.Close()
|
||||
io.Copy(w, body)
|
||||
})
|
||||
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
|
||||
// streaming writer variant forces fasthttp into unknown-length chunked
|
||||
// transfer, dropping the header we just set above.
|
||||
return c.SendStream(body, int(objectInfo.Size))
|
||||
}
|
||||
|
||||
// getObjectRange serves a single-range request with 206 Partial Content.
|
||||
// Malformed and multi-range headers fall back to the full 200 response.
|
||||
func (h *ObjectHandler) getObjectRange(c fiber.Ctx, bucketName, key, rangeHeader string) error {
|
||||
ctx := c.Context()
|
||||
|
||||
info, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
rng, unsatisfiable := parseRangeHeader(rangeHeader, info.Size)
|
||||
if unsatisfiable {
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Content-Range", "bytes */"+strconv.FormatInt(info.Size, 10))
|
||||
return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
|
||||
}
|
||||
if rng == nil {
|
||||
return h.serveFullObject(c, bucketName, key)
|
||||
}
|
||||
|
||||
body, err := h.s3Service.GetObjectRange(ctx, bucketName, key, rng.start, rng.end)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
c.Set("Content-Type", safeContentType(info.ContentType))
|
||||
c.Set("X-Content-Type-Options", "nosniff")
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Content-Length", strconv.FormatInt(rng.end-rng.start+1, 10))
|
||||
c.Set("Content-Range", "bytes "+strconv.FormatInt(rng.start, 10)+"-"+strconv.FormatInt(rng.end, 10)+"/"+strconv.FormatInt(info.Size, 10))
|
||||
c.Set("ETag", info.ETag)
|
||||
c.Set("Last-Modified", info.LastModified.Format(time.RFC1123))
|
||||
|
||||
disposition := "inline"
|
||||
if c.Query("download") == "true" {
|
||||
disposition = "attachment"
|
||||
}
|
||||
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
|
||||
|
||||
c.Status(fiber.StatusPartialContent)
|
||||
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
|
||||
// streaming writer variant forces fasthttp into unknown-length chunked
|
||||
// transfer, dropping the header we just set above.
|
||||
return c.SendStream(body, int(rng.end-rng.start+1))
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
@@ -412,6 +495,7 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
return c.JSON(models.SuccessResponse(metadata))
|
||||
}
|
||||
|
||||
@@ -496,6 +580,48 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// GetPreviewURL mints a short-lived tokenized URL for streaming this object
|
||||
//
|
||||
// @Summary Get a tokenized preview URL for an object
|
||||
// @Description Returns a relative URL carrying a short-lived token that authorizes streaming this object. Media elements cannot send an Authorization header, so the token rides in the URL instead.
|
||||
// @Tags Objects
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PreviewURLResponse} "Preview URL minted"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to mint the preview token"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key}/preview-url [get]
|
||||
func (h *ObjectHandler) GetPreviewURL(c fiber.Ctx) error {
|
||||
bucketName := c.Params("bucket")
|
||||
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
token, expiresAt, err := h.previewTokens.MintPreviewToken(bucketName, key, previewTokenTTL)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to mint the preview token: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
previewURL := "/api/v1/buckets/" + url.PathEscape(bucketName) +
|
||||
"/objects/" + url.PathEscape(key) + "?pt=" + url.QueryEscape(token)
|
||||
|
||||
return c.JSON(models.SuccessResponse(models.PreviewURLResponse{
|
||||
URL: previewURL,
|
||||
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
|
||||
}))
|
||||
}
|
||||
|
||||
// DeleteMultipleObjects deletes multiple objects from a bucket
|
||||
//
|
||||
// @Summary Delete multiple objects from bucket
|
||||
@@ -503,8 +629,8 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the objects"
|
||||
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
|
||||
// @Param bucket path string true "Name of the bucket containing the objects"
|
||||
// @Param request body object{keys=[]string,prefixes=[]string} true "Object keys to delete and/or folder prefixes to delete recursively"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
@@ -521,10 +647,11 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body to get keys and optional prefix
|
||||
// Parse request body. "keys" are concrete objects to delete; "prefixes" are
|
||||
// folders to delete recursively (every object stored under the prefix).
|
||||
var req struct {
|
||||
Keys []string `json:"keys"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Keys []string `json:"keys"`
|
||||
Prefixes []string `json:"prefixes,omitempty"`
|
||||
}
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
@@ -532,23 +659,61 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
if len(req.Keys) == 0 {
|
||||
if len(req.Keys) == 0 && len(req.Prefixes) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key or prefix is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// 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()),
|
||||
)
|
||||
// Validate and normalize folder prefixes before running an irreversible
|
||||
// recursive delete on a public endpoint. A blank prefix would match the
|
||||
// entire bucket, and a prefix without a trailing slash (e.g. "photos/2024")
|
||||
// would also match sibling keys such as "photos/2024-old/...". Reject blanks
|
||||
// with a 4XX and force a trailing slash so a prefix only ever deletes the
|
||||
// objects inside its own folder.
|
||||
prefixes := make([]string, 0, len(req.Prefixes))
|
||||
for _, p := range req.Prefixes {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Prefix must not be blank"),
|
||||
)
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, "/") {
|
||||
trimmed += "/"
|
||||
}
|
||||
prefixes = append(prefixes, trimmed)
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
|
||||
// Delete the individually selected objects in a single batch call.
|
||||
if len(req.Keys) > 0 {
|
||||
n, err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
|
||||
)
|
||||
}
|
||||
deleted += n
|
||||
}
|
||||
|
||||
// Recursively delete every object under each selected folder prefix.
|
||||
for _, prefix := range prefixes {
|
||||
n, err := h.s3Service.DeleteObjectsByPrefix(ctx, bucketName, prefix)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete folder "+prefix+": "+err.Error()),
|
||||
)
|
||||
}
|
||||
deleted += n
|
||||
}
|
||||
|
||||
response := models.ObjectDeleteMultipleResponse{
|
||||
Bucket: bucketName,
|
||||
Deleted: len(req.Keys),
|
||||
Keys: req.Keys,
|
||||
Bucket: bucketName,
|
||||
Deleted: deleted,
|
||||
Keys: req.Keys,
|
||||
Prefixes: prefixes,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
|
||||
@@ -20,23 +20,42 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// mintStub satisfies PreviewTokenMinter for handler tests.
|
||||
type mintStub struct {
|
||||
fn func(bucket, key string, ttl time.Duration) (string, time.Time, error)
|
||||
}
|
||||
|
||||
func (m *mintStub) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
|
||||
if m.fn == nil {
|
||||
return "test-token", time.Now().Add(ttl), nil
|
||||
}
|
||||
return m.fn(bucket, key, ttl)
|
||||
}
|
||||
|
||||
func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
|
||||
app, s3, _ := newObjectsTestAppWithMinter(t)
|
||||
return app, s3
|
||||
}
|
||||
|
||||
func newObjectsTestAppWithMinter(t *testing.T) (*fiber.App, *mocks.S3Mock, *mintStub) {
|
||||
t.Helper()
|
||||
s3 := &mocks.S3Mock{}
|
||||
h := NewObjectHandler(s3)
|
||||
minter := &mintStub{}
|
||||
h := NewObjectHandler(s3, minter)
|
||||
app := fiber.New()
|
||||
app.Get("/buckets/:bucket/objects", h.ListObjects)
|
||||
app.Post("/buckets/:bucket/objects", h.UploadObject)
|
||||
app.Post("/buckets/:bucket/directories", h.CreateDirectory)
|
||||
app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects)
|
||||
app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects)
|
||||
// Wildcard endpoints — mount under :key for tests. Handlers prefer
|
||||
// Wildcard endpoints. Mount under :key for tests. Handlers prefer
|
||||
// c.Locals("objectKey") but fall back to c.Params("key"), so :key works.
|
||||
app.Get("/buckets/:bucket/objects/:key", h.GetObject)
|
||||
app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata)
|
||||
app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL)
|
||||
app.Get("/buckets/:bucket/objects/:key/preview-url", h.GetPreviewURL)
|
||||
app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject)
|
||||
return app, s3
|
||||
return app, s3, minter
|
||||
}
|
||||
|
||||
// --- ListObjects ---
|
||||
@@ -120,6 +139,52 @@ 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) {
|
||||
@@ -297,6 +362,109 @@ func TestGetPresignedURL_ObjectMissing404(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetPreviewURL ---
|
||||
|
||||
func TestGetPreviewURL_Success(t *testing.T) {
|
||||
app, _, minter := newObjectsTestAppWithMinter(t)
|
||||
fixed := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
|
||||
minter.fn = func(bucket, key string, ttl time.Duration) (string, time.Time, error) {
|
||||
if bucket != "b1" || key != "clip.mp4" {
|
||||
t.Errorf("mint args = (%q, %q)", bucket, key)
|
||||
}
|
||||
if ttl != time.Hour {
|
||||
t.Errorf("ttl = %v, want 1h", ttl)
|
||||
}
|
||||
return "tok123", fixed, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4/preview-url", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.PreviewURLResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.URL != "/api/v1/buckets/b1/objects/clip.mp4?pt=tok123" {
|
||||
t.Errorf("url = %q", body.Data.URL)
|
||||
}
|
||||
if body.Data.ExpiresAt != "2026-07-11T12:00:00Z" {
|
||||
t.Errorf("expires_at = %q", body.Data.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPreviewURL_EscapesKeyInURL(t *testing.T) {
|
||||
// Production sets the decoded key in locals via the wildcard dispatcher,
|
||||
// so mirror that here instead of relying on :key param decoding.
|
||||
s3 := &mocks.S3Mock{}
|
||||
minter := &mintStub{}
|
||||
minter.fn = func(_, key string, _ time.Duration) (string, time.Time, error) {
|
||||
if key != "dir/my file.mp4" {
|
||||
t.Errorf("key = %q", key)
|
||||
}
|
||||
return "tok", time.Now().Add(time.Hour), nil
|
||||
}
|
||||
h := NewObjectHandler(s3, minter)
|
||||
app := fiber.New()
|
||||
app.Get("/buckets/:bucket/preview-url", func(c fiber.Ctx) error {
|
||||
c.Locals("objectKey", "dir/my file.mp4")
|
||||
return h.GetPreviewURL(c)
|
||||
})
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/preview-url", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.PreviewURLResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if !strings.HasPrefix(body.Data.URL, "/api/v1/buckets/b1/objects/dir%2Fmy%20file.mp4?pt=") {
|
||||
t.Errorf("url = %q, want the key percent-encoded whole", body.Data.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPreviewURL_MissingBucketAndKey400(t *testing.T) {
|
||||
// Mount on a route with no :bucket param and no objectKey local, so both
|
||||
// bucket and key are empty and the handler short-circuits with 400.
|
||||
s3 := &mocks.S3Mock{}
|
||||
minter := &mintStub{}
|
||||
h := NewObjectHandler(s3, minter)
|
||||
app := fiber.New()
|
||||
app.Get("/preview-url-nobucket", h.GetPreviewURL)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/preview-url-nobucket", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPreviewURL_MintError500(t *testing.T) {
|
||||
app, _, minter := newObjectsTestAppWithMinter(t)
|
||||
minter.fn = func(_, _ string, _ time.Duration) (string, time.Time, error) {
|
||||
return "", time.Time{}, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f/preview-url", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetObject ---
|
||||
|
||||
func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) {
|
||||
@@ -519,11 +687,11 @@ func TestUploadObject_ServiceError500(t *testing.T) {
|
||||
|
||||
func TestDeleteMultipleObjects_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error {
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) (int, error) {
|
||||
if bucket != "b1" || len(keys) != 3 {
|
||||
t.Errorf("args = (%q, %v)", bucket, keys)
|
||||
}
|
||||
return nil
|
||||
return len(keys), nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
@@ -545,6 +713,81 @@ func TestDeleteMultipleObjects_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_Prefixes_Recursive(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteObjectsByPrefixFn = func(_ context.Context, bucket, prefix string) (int, error) {
|
||||
if bucket != "b1" || prefix != "docs/" {
|
||||
t.Errorf("args = (%q, %q)", bucket, prefix)
|
||||
}
|
||||
return 4, nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"prefixes": []string{"docs/"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Data models.ObjectDeleteMultipleResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &out)
|
||||
if out.Data.Deleted != 4 {
|
||||
t.Errorf("Deleted = %d, want 4", out.Data.Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_KeysAndPrefixes(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, keys []string) (int, error) {
|
||||
if len(keys) != 2 {
|
||||
t.Errorf("keys = %v", keys)
|
||||
}
|
||||
return len(keys), nil
|
||||
}
|
||||
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) { return 3, nil }
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b"}, "prefixes": []string{"docs/"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Data models.ObjectDeleteMultipleResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &out)
|
||||
if out.Data.Deleted != 5 {
|
||||
t.Errorf("Deleted = %d, want 5 (2 keys + 3 under prefix)", out.Data.Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_PrefixError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, errors.New("boom")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"prefixes": []string{"docs/"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{}})
|
||||
@@ -560,6 +803,54 @@ func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_BlankPrefix400(t *testing.T) {
|
||||
// A blank/whitespace-only prefix must be rejected with a 4XX before any
|
||||
// delete is attempted — it would otherwise target the whole bucket.
|
||||
for _, prefix := range []string{"", " "} {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) {
|
||||
t.Errorf("DeleteObjectsByPrefix must not be called for blank prefix %q", prefix)
|
||||
return 0, nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"prefixes": []string{prefix}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("prefix %q: status = %d, want 400", prefix, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_PrefixNormalizedToTrailingSlash(t *testing.T) {
|
||||
// A prefix without a trailing slash must be normalized so it only deletes
|
||||
// its own folder ("photos/2024/"), not siblings like "photos/2024-old/".
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
var gotPrefix string
|
||||
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, prefix string) (int, error) {
|
||||
gotPrefix = prefix
|
||||
return 1, nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"prefixes": []string{"photos/2024"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if gotPrefix != "photos/2024/" {
|
||||
t.Errorf("prefix passed to service = %q, want %q", gotPrefix, "photos/2024/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json"))
|
||||
@@ -576,7 +867,7 @@ func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
|
||||
|
||||
func TestDeleteMultipleObjects_ServiceError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") }
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) (int, error) { return 0, errors.New("boom") }
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{"a"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -864,3 +1155,186 @@ func TestCreateDirectory_ServiceError500(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetObject Range support ---
|
||||
|
||||
func TestGetObject_NoRangeHeaderAdvertisesAcceptRanges(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(strings.NewReader("0123456789")), &models.ObjectInfo{Key: key, Size: 10, ContentType: "text/plain", LastModified: time.Now()}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.txt", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
|
||||
t.Errorf("Accept-Ranges = %q, want %q", got, "bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_RangeRequestServes206(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
now := time.Now()
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
||||
return &models.ObjectInfo{Key: key, Size: 10, ContentType: "video/mp4", ETag: "e1", LastModified: now}, nil
|
||||
}
|
||||
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
|
||||
if start != 2 || end != 6 {
|
||||
t.Errorf("range = %d-%d, want 2-6", start, end)
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("23456")), nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4", nil)
|
||||
req.Header.Set("Range", "bytes=2-6")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Range"); got != "bytes 2-6/10" {
|
||||
t.Errorf("Content-Range = %q, want %q", got, "bytes 2-6/10")
|
||||
}
|
||||
if got := resp.Header.Get("Content-Length"); got != "5" {
|
||||
t.Errorf("Content-Length = %q, want %q", got, "5")
|
||||
}
|
||||
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
|
||||
t.Errorf("Accept-Ranges = %q, want %q", got, "bytes")
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "23456" {
|
||||
t.Errorf("body = %q, want %q", body, "23456")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_UnsatisfiableRangeServes416(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
||||
return &models.ObjectInfo{Key: key, Size: 10}, nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.bin", nil)
|
||||
req.Header.Set("Range", "bytes=50-")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
||||
t.Fatalf("status = %d, want 416", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Range"); got != "bytes */10" {
|
||||
t.Errorf("Content-Range = %q, want %q", got, "bytes */10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_MultiRangeFallsBackToFullResponse(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
||||
return &models.ObjectInfo{Key: key, Size: 10}, nil
|
||||
}
|
||||
s3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(strings.NewReader("0123456789")), &models.ObjectInfo{Key: key, Size: 10, LastModified: time.Now()}, nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.bin", nil)
|
||||
req.Header.Set("Range", "bytes=0-1,3-4")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "0123456789" {
|
||||
t.Errorf("body = %q, want the full object", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_RangeForMissingObjectIs404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
|
||||
return nil, errors.New("no such key")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/gone.bin", nil)
|
||||
req.Header.Set("Range", "bytes=0-5")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A ranged read whose metadata resolves but whose byte fetch fails, for example
|
||||
// when the object is deleted between the two calls, returns 404.
|
||||
func TestGetObject_RangeReadErrorIs404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
||||
return &models.ObjectInfo{Key: key, Size: 10}, nil
|
||||
}
|
||||
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, _, _ int64) (io.ReadCloser, error) {
|
||||
return nil, errors.New("read failed")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4", nil)
|
||||
req.Header.Set("Range", "bytes=0-5")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A ranged request with download=true still streams 206 but marks the body as
|
||||
// an attachment instead of inline.
|
||||
func TestGetObject_RangeWithDownloadSetsAttachment(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
||||
return &models.ObjectInfo{Key: key, Size: 10, ContentType: "video/mp4"}, nil
|
||||
}
|
||||
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, _, _ int64) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("01234")), nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4?download=true", nil)
|
||||
req.Header.Set("Range", "bytes=0-4")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
|
||||
t.Errorf("Content-Disposition = %q, want attachment", got)
|
||||
}
|
||||
}
|
||||
|
||||
// GetObject rejects a request that resolves to an empty object key with 400.
|
||||
// This guards the wildcard dispatch path where the key comes from locals.
|
||||
func TestGetObject_EmptyKeyIsBadRequest(t *testing.T) {
|
||||
s3 := &mocks.S3Mock{}
|
||||
h := NewObjectHandler(s3, &mintStub{})
|
||||
app := fiber.New()
|
||||
// Mounted without a :key param so the handler resolves an empty key.
|
||||
app.Get("/buckets/:bucket/object", h.GetObject)
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/object", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// byteRange is a resolved, inclusive byte range within an object.
|
||||
type byteRange struct {
|
||||
start int64
|
||||
end int64
|
||||
}
|
||||
|
||||
// parseRangeHeader resolves a Range request header against the object size.
|
||||
// It supports a single "bytes=" range in its three forms: start-end, start-,
|
||||
// and -suffix. A nil result with unsatisfiable false means serve the full
|
||||
// object with 200; absent, malformed, and multi-range headers all land there,
|
||||
// which RFC 9110 permits. unsatisfiable true means respond 416.
|
||||
func parseRangeHeader(header string, size int64) (rng *byteRange, unsatisfiable bool) {
|
||||
spec, ok := strings.CutPrefix(header, "bytes=")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
spec = strings.TrimSpace(spec)
|
||||
if strings.Contains(spec, ",") {
|
||||
return nil, false
|
||||
}
|
||||
startStr, endStr, ok := strings.Cut(spec, "-")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
startStr = strings.TrimSpace(startStr)
|
||||
endStr = strings.TrimSpace(endStr)
|
||||
|
||||
// Suffix form "-n" asks for the last n bytes.
|
||||
if startStr == "" {
|
||||
n, err := strconv.ParseInt(endStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if n <= 0 || size == 0 {
|
||||
return nil, true
|
||||
}
|
||||
if n > size {
|
||||
n = size
|
||||
}
|
||||
return &byteRange{start: size - n, end: size - 1}, false
|
||||
}
|
||||
|
||||
start, err := strconv.ParseInt(startStr, 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return nil, false
|
||||
}
|
||||
if start >= size {
|
||||
return nil, true
|
||||
}
|
||||
if endStr == "" {
|
||||
return &byteRange{start: start, end: size - 1}, false
|
||||
}
|
||||
end, err := strconv.ParseInt(endStr, 10, 64)
|
||||
if err != nil || end < start {
|
||||
return nil, false
|
||||
}
|
||||
if end >= size {
|
||||
end = size - 1
|
||||
}
|
||||
return &byteRange{start: start, end: end}, false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseRangeHeader(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
size int64
|
||||
wantStart int64
|
||||
wantEnd int64
|
||||
wantRange bool
|
||||
wantUnsatisfy bool
|
||||
}{
|
||||
{name: "absent header serves full", header: "", size: 10, wantRange: false},
|
||||
{name: "simple range", header: "bytes=2-6", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
|
||||
{name: "open ended", header: "bytes=500-", size: 1000, wantStart: 500, wantEnd: 999, wantRange: true},
|
||||
{name: "suffix", header: "bytes=-300", size: 1000, wantStart: 700, wantEnd: 999, wantRange: true},
|
||||
{name: "suffix larger than object clamps to full", header: "bytes=-5000", size: 1000, wantStart: 0, wantEnd: 999, wantRange: true},
|
||||
{name: "end clamped to size", header: "bytes=0-99999", size: 100, wantStart: 0, wantEnd: 99, wantRange: true},
|
||||
{name: "single byte", header: "bytes=0-0", size: 10, wantStart: 0, wantEnd: 0, wantRange: true},
|
||||
{name: "start beyond size is unsatisfiable", header: "bytes=100-", size: 100, wantUnsatisfy: true},
|
||||
{name: "suffix zero is unsatisfiable", header: "bytes=-0", size: 100, wantUnsatisfy: true},
|
||||
{name: "any range on empty object is unsatisfiable", header: "bytes=0-", size: 0, wantUnsatisfy: true},
|
||||
{name: "suffix on empty object is unsatisfiable", header: "bytes=-5", size: 0, wantUnsatisfy: true},
|
||||
{name: "multi range ignored", header: "bytes=0-1,3-4", size: 10, wantRange: false},
|
||||
{name: "non byte unit ignored", header: "items=0-5", size: 10, wantRange: false},
|
||||
{name: "end before start ignored", header: "bytes=6-2", size: 10, wantRange: false},
|
||||
{name: "garbage start ignored", header: "bytes=abc-5", size: 10, wantRange: false},
|
||||
{name: "garbage end ignored", header: "bytes=5-abc", size: 10, wantRange: false},
|
||||
{name: "garbage suffix ignored", header: "bytes=-abc", size: 10, wantRange: false},
|
||||
{name: "negative start ignored", header: "bytes=-5-8", size: 10, wantRange: false},
|
||||
{name: "missing dash ignored", header: "bytes=5", size: 10, wantRange: false},
|
||||
{name: "whitespace tolerated", header: "bytes= 2-6 ", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rng, unsatisfiable := parseRangeHeader(tc.header, tc.size)
|
||||
if unsatisfiable != tc.wantUnsatisfy {
|
||||
t.Fatalf("unsatisfiable = %v, want %v", unsatisfiable, tc.wantUnsatisfy)
|
||||
}
|
||||
if tc.wantRange {
|
||||
if rng == nil {
|
||||
t.Fatalf("rng = nil, want %d-%d", tc.wantStart, tc.wantEnd)
|
||||
}
|
||||
if rng.start != tc.wantStart || rng.end != tc.wantEnd {
|
||||
t.Errorf("rng = %d-%d, want %d-%d", rng.start, rng.end, tc.wantStart, tc.wantEnd)
|
||||
}
|
||||
} else if rng != nil {
|
||||
t.Errorf("rng = %d-%d, want nil", rng.start, rng.end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
@@ -23,6 +24,31 @@ func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Han
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Preview tokens authenticate object GETs from media elements, which
|
||||
// cannot send an Authorization header. The token was minted behind an
|
||||
// object.read check, names one exact object, and expires on its own.
|
||||
//
|
||||
// Bucket and key come from the raw request path (previewRouteParts),
|
||||
// not from c.Params("bucket")/c.Params("*"). routes.go registers this
|
||||
// AuthMiddleware twice for the object GET route: once cascaded from
|
||||
// the /api/v1 group's Use middleware (which runs before Fiber has
|
||||
// matched the specific wildcard route, so its params are not bound
|
||||
// yet) and once more directly on the route itself (params bound). The
|
||||
// group-cascaded pass would otherwise dead-end here on empty params
|
||||
// and fall through to a 401 before the bound-params pass ever runs.
|
||||
// Parsing the static path shape gives the same, correct answer in
|
||||
// both positions without weakening the contract: it only ever
|
||||
// resolves the exact bucket and key named in the URL.
|
||||
if pt := c.Query("pt"); pt != "" && c.Method() == fiber.MethodGet {
|
||||
bucket, _ := previewRouteParts(c)
|
||||
key := previewObjectKey(c)
|
||||
if bucket != "" && key != "" && authService.ValidatePreviewToken(pt, bucket, key) == nil {
|
||||
c.Locals(auth.PreviewTokenLocalsKey, &auth.PreviewClaims{Bucket: bucket, Key: key})
|
||||
enrichRequestLogger(c, "preview-token", "preview_token")
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
authHeader := c.Get("Authorization")
|
||||
|
||||
// Try bearer token auth (works for admin, token, or any JWT session)
|
||||
@@ -97,3 +123,61 @@ func authMethodsEnabled(cfg *config.AuthConfig) string {
|
||||
}
|
||||
return strings.Join(methods, "+")
|
||||
}
|
||||
|
||||
// previewRouteParts extracts the bucket and raw (still percent-encoded)
|
||||
// object key from a request path shaped like
|
||||
// "/api/v1/buckets/<bucket>/objects/<key>", the only shape the object GET
|
||||
// route matches. It parses c.Path() directly rather than reading Fiber's
|
||||
// bound :bucket/* route params, because those params are unset whenever this
|
||||
// runs ahead of the specific route match (see the AuthMiddleware comment
|
||||
// above). c.Path() reflects the incoming request path from the start of
|
||||
// request handling, independent of routing state, so this returns the same
|
||||
// answer no matter where in the chain it runs. Any path not matching the
|
||||
// shape returns ("", "").
|
||||
func previewRouteParts(c fiber.Ctx) (bucket, rawKey string) {
|
||||
const prefix = "/api/v1/buckets/"
|
||||
path := c.Path()
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", ""
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
slash := strings.IndexByte(rest, '/')
|
||||
if slash < 0 {
|
||||
return "", ""
|
||||
}
|
||||
bucket = rest[:slash]
|
||||
rest = rest[slash+1:]
|
||||
const objectsPrefix = "objects/"
|
||||
if !strings.HasPrefix(rest, objectsPrefix) {
|
||||
return "", ""
|
||||
}
|
||||
return bucket, rest[len(objectsPrefix):]
|
||||
}
|
||||
|
||||
// previewObjectKey decodes the wildcard object key the same way the routes
|
||||
// layer does. Requests targeting the JSON subroutes return "" because a
|
||||
// preview token only ever grants the plain byte download.
|
||||
func previewObjectKey(c fiber.Ctx) string {
|
||||
_, raw := previewRouteParts(c)
|
||||
// A raw trailing slash is the one case where c.Path() (used here) and the
|
||||
// served c.Params("*") diverge: Fiber trims the trailing slash from the
|
||||
// bound wildcard, so validating against the un-trimmed key could authorize
|
||||
// a token for "dir/" to serve "dir", or let "x/metadata/" reach the
|
||||
// /metadata subroute. The SPA always percent-encodes keys as one segment,
|
||||
// so a legitimate key ending in "/" arrives as "...%2F", never a raw
|
||||
// trailing slash. Refuse the raw-trailing-slash form so the validated key
|
||||
// can never diverge from the served key.
|
||||
if raw == "" || strings.HasSuffix(raw, "/") {
|
||||
return ""
|
||||
}
|
||||
decoded, err := url.QueryUnescape(raw)
|
||||
if err != nil {
|
||||
decoded = raw
|
||||
}
|
||||
for _, suffix := range []string{"/metadata", "/presign", "/preview-url"} {
|
||||
if strings.HasSuffix(decoded, suffix) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
@@ -401,3 +404,207 @@ func TestAuthMiddleware_Both_AllInvalid_Returns401WithCombinedMethodLabel(t *tes
|
||||
t.Errorf("auth_method = %v, want admin+oidc", warn["auth_method"])
|
||||
}
|
||||
}
|
||||
|
||||
// newPreviewTokenApp mirrors the production object GET route shape. Note it
|
||||
// registers AuthMiddleware via a bare app.Use(), same as routes.go's /api/v1
|
||||
// group cascade: bucket and key are read from the raw request path
|
||||
// (previewRouteParts), not from c.Params("bucket")/c.Params("*"), precisely
|
||||
// because those Fiber route params are not yet bound when a Use()-registered
|
||||
// middleware executes ahead of the specific :bucket/* route match. routes.go
|
||||
// registers AuthMiddleware a second time directly on the object route too;
|
||||
// this test only needs one registration to exercise the same path-parsing
|
||||
// code the real group cascade hits first.
|
||||
func newPreviewTokenApp(t *testing.T, authCfg *config.AuthConfig, svc *auth.Service) *fiber.App {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
app.Use(AuthMiddleware(authCfg, svc))
|
||||
handler := func(c fiber.Ctx) error {
|
||||
claims, _ := c.Locals(auth.PreviewTokenLocalsKey).(*auth.PreviewClaims)
|
||||
if claims == nil {
|
||||
return c.SendString("no-claims")
|
||||
}
|
||||
return c.SendString("claims:" + claims.Bucket + "/" + claims.Key)
|
||||
}
|
||||
app.Get("/api/v1/buckets/:bucket/objects/*", handler)
|
||||
app.Delete("/api/v1/buckets/:bucket/objects/*", handler)
|
||||
return app
|
||||
}
|
||||
|
||||
func previewAuthConfig() *config.AuthConfig {
|
||||
return &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_ValidPreviewTokenAllowsObjectGET(t *testing.T) {
|
||||
authCfg := previewAuthConfig()
|
||||
svc := newAuthSvc(t, authCfg)
|
||||
app := newPreviewTokenApp(t, authCfg, svc)
|
||||
|
||||
token, _, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/api/v1/buckets/b1/objects/dir%2Fclip.mp4?pt="+url.QueryEscape(token), nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "claims:b1/dir/clip.mp4" {
|
||||
t.Errorf("body = %q, want the preview claims set in locals", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_PreviewTokenRejections(t *testing.T) {
|
||||
authCfg := previewAuthConfig()
|
||||
svc := newAuthSvc(t, authCfg)
|
||||
app := newPreviewTokenApp(t, authCfg, svc)
|
||||
|
||||
good, _, err := svc.MintPreviewToken("b1", "k.mp4", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
expired, _, err := svc.MintPreviewToken("b1", "k.mp4", -time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
// A token whose claimed key genuinely ends in a slash. On the wire the SPA
|
||||
// sends this as one percent-encoded segment ("dir%2F"); this case instead
|
||||
// sends the ambiguous raw form ("dir/"). c.Path() keeps the trailing
|
||||
// slash, but the served c.Params("*") would be trimmed to "dir", so the
|
||||
// token would name "dir/" while the handler serves "dir": a different
|
||||
// object. previewObjectKey refuses the raw-trailing-slash form, so this
|
||||
// falls through to normal auth and 401s.
|
||||
trailingDir, _, err := svc.MintPreviewToken("b1", "dir/", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
// A token for a key ending in "/metadata/". Decoded it is "x/metadata/",
|
||||
// whose HasSuffix "/metadata" is false because of the trailing slash, so
|
||||
// the subroute guard would not fire; but the served c.Params("*") is
|
||||
// trimmed to "x/metadata" and routes to the /metadata subroute. The
|
||||
// raw-trailing-slash refusal blocks this before either divergence matters.
|
||||
trailingMeta, _, err := svc.MintPreviewToken("b1", "x/metadata/", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{name: "wrong key", method: "GET", path: "/api/v1/buckets/b1/objects/other.mp4?pt=" + url.QueryEscape(good)},
|
||||
{name: "wrong bucket", method: "GET", path: "/api/v1/buckets/b2/objects/k.mp4?pt=" + url.QueryEscape(good)},
|
||||
{name: "expired", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4?pt=" + url.QueryEscape(expired)},
|
||||
{name: "metadata subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fmetadata?pt=" + url.QueryEscape(good)},
|
||||
{name: "presign subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fpresign?pt=" + url.QueryEscape(good)},
|
||||
{name: "preview-url subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fpreview-url?pt=" + url.QueryEscape(good)},
|
||||
{name: "delete method", method: "DELETE", path: "/api/v1/buckets/b1/objects/k.mp4?pt=" + url.QueryEscape(good)},
|
||||
{name: "garbage token", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4?pt=garbage"},
|
||||
{name: "raw trailing slash key", method: "GET", path: "/api/v1/buckets/b1/objects/dir/?pt=" + url.QueryEscape(trailingDir)},
|
||||
{name: "raw trailing slash reaching metadata subroute", method: "GET", path: "/api/v1/buckets/b1/objects/x/metadata/?pt=" + url.QueryEscape(trailingMeta)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, err := app.Test(httptest.NewRequest(tc.method, tc.path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Errorf("status = %d, want 401 fallthrough to normal auth", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewRouteParts exercises previewRouteParts directly against every
|
||||
// branch of its path-shape parsing: no "/api/v1/buckets/" prefix, a bucket
|
||||
// segment with nothing after it, a bucket segment followed by something
|
||||
// other than "objects/", and the well formed shape. This parsing runs in
|
||||
// place of Fiber's :bucket/* param binding (see the comment on
|
||||
// previewRouteParts), so its edge cases need direct coverage independent of
|
||||
// AuthMiddleware's own tests.
|
||||
func TestPreviewRouteParts(t *testing.T) {
|
||||
app := fiber.New()
|
||||
var gotBucket, gotKey string
|
||||
app.Get("/*", func(c fiber.Ctx) error {
|
||||
gotBucket, gotKey = previewRouteParts(c)
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantBucket string
|
||||
wantKey string
|
||||
}{
|
||||
{name: "no buckets prefix", path: "/other/path", wantBucket: "", wantKey: ""},
|
||||
{name: "bucket segment with no trailing slash", path: "/api/v1/buckets/mybucket", wantBucket: "", wantKey: ""},
|
||||
{name: "segment after bucket is not objects", path: "/api/v1/buckets/mybucket/permissions", wantBucket: "", wantKey: ""},
|
||||
{name: "well formed", path: "/api/v1/buckets/mybucket/objects/dir%2Fclip.mp4", wantBucket: "mybucket", wantKey: "dir%2Fclip.mp4"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotBucket, gotKey = "unset", "unset"
|
||||
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if gotBucket != tc.wantBucket || gotKey != tc.wantKey {
|
||||
t.Errorf("previewRouteParts(%q) = (%q, %q), want (%q, %q)", tc.path, gotBucket, gotKey, tc.wantBucket, tc.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewObjectKey covers previewObjectKey's own branches beyond what
|
||||
// the AuthMiddleware rejection tests exercise incidentally: a plain key with
|
||||
// no reserved suffix decodes normally, and a path that previewRouteParts
|
||||
// can't parse at all yields "".
|
||||
func TestPreviewObjectKey(t *testing.T) {
|
||||
app := fiber.New()
|
||||
var got string
|
||||
app.Get("/*", func(c fiber.Ctx) error {
|
||||
got = previewObjectKey(c)
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{name: "plain key decodes", path: "/api/v1/buckets/b/objects/dir%2Fclip.mp4", want: "dir/clip.mp4"},
|
||||
{name: "unparseable route returns empty", path: "/not-a-bucket-route", want: ""},
|
||||
// A key that genuinely ends in a slash is legitimate when the SPA sends
|
||||
// it as one encoded segment ("dir%2F"): raw has no literal trailing
|
||||
// slash, so it is accepted and decodes to "dir/", matching the served
|
||||
// key. Only the raw-trailing-slash form is refused.
|
||||
{name: "encoded trailing slash accepted", path: "/api/v1/buckets/b/objects/dir%2F", want: "dir/"},
|
||||
// The ambiguous raw-trailing-slash form is refused (returns "").
|
||||
{name: "raw trailing slash refused", path: "/api/v1/buckets/b/objects/dir/", want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got = "unset"
|
||||
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("previewObjectKey(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ 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
|
||||
|
||||
@@ -48,6 +48,10 @@ type BucketInfo struct {
|
||||
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
|
||||
@@ -146,10 +150,16 @@ type PresignedURLResponse struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type PreviewURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type ObjectDeleteMultipleResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Deleted int `json:"deleted"`
|
||||
Keys []string `json:"keys"`
|
||||
Bucket string `json:"bucket"`
|
||||
Deleted int `json:"deleted"`
|
||||
Keys []string `json:"keys"`
|
||||
Prefixes []string `json:"prefixes,omitempty"`
|
||||
}
|
||||
|
||||
// UserListResponse represents a list of users/keys
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,6 +31,7 @@ func SetupRoutes(
|
||||
clusterHandler *handlers.ClusterHandler,
|
||||
monitoringHandler *handlers.MonitoringHandler,
|
||||
capabilitiesHandler *handlers.CapabilitiesHandler,
|
||||
az *authz.Middleware,
|
||||
) {
|
||||
// Apply CORS middleware globally
|
||||
app.Use(middleware.CORSMiddleware(&cfg.CORS))
|
||||
@@ -47,37 +49,50 @@ func SetupRoutes(
|
||||
// Auth configuration endpoint (always accessible, no auth required)
|
||||
app.Get("/auth/config", authHandler.GetAuthConfig)
|
||||
|
||||
// Public Prometheus metrics endpoint (no auth), opt-in via auth.metrics_public.
|
||||
// Registered outside /api/v1 so it bypasses the AuthMiddleware/ResolveSubject
|
||||
// cascade and the VerifyRouteCoverage fail-closed guard entirely; the
|
||||
// authenticated /api/v1/monitoring/metrics route is unaffected. Because it is
|
||||
// registered before the SPA fallback below, Fiber matches it first.
|
||||
// Protect it at the network layer (NetworkPolicy / trusted scrape network).
|
||||
if cfg.Auth.MetricsPublic {
|
||||
app.Get("/metrics", monitoringHandler.GetMetrics)
|
||||
}
|
||||
|
||||
// API v1 group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Apply authentication middleware to all API routes
|
||||
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
|
||||
|
||||
// Resolve the authz Subject once per request, right after authentication.
|
||||
api.Use(az.ResolveSubject())
|
||||
|
||||
api.Get("/capabilities", capabilitiesHandler.GetCapabilities)
|
||||
|
||||
// Bucket routes
|
||||
buckets := api.Group("/buckets")
|
||||
{
|
||||
buckets.Get("/", 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
|
||||
buckets.Put("/:name/quotas", bucketHandler.UpdateBucketQuotas) // Update bucket quotas
|
||||
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
|
||||
}
|
||||
|
||||
// Object routes
|
||||
objects := api.Group("/buckets/:bucket/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
|
||||
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
|
||||
}
|
||||
|
||||
// Directory routes (zero-byte directory markers)
|
||||
api.Post("/buckets/:bucket/directories", objectHandler.CreateDirectory)
|
||||
api.Post("/buckets/:bucket/directories", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.CreateDirectory)
|
||||
|
||||
// Fiber v3 does not auto-decode wildcard params; fall back to the raw
|
||||
// value when QueryUnescape fails.
|
||||
@@ -98,6 +113,9 @@ func SetupRoutes(
|
||||
case strings.HasSuffix(path, "/presign"):
|
||||
c.Locals("objectKey", strings.TrimSuffix(path, "/presign"))
|
||||
return objectHandler.GetPresignedURL(c)
|
||||
case strings.HasSuffix(path, "/preview-url"):
|
||||
c.Locals("objectKey", strings.TrimSuffix(path, "/preview-url"))
|
||||
return objectHandler.GetPreviewURL(c)
|
||||
default:
|
||||
c.Locals("objectKey", path)
|
||||
return objectHandler.GetObject(c)
|
||||
@@ -114,38 +132,41 @@ func SetupRoutes(
|
||||
return objectHandler.GetObjectMetadata(c)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
|
||||
// User/Key management routes
|
||||
users := api.Group("/users")
|
||||
{
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// Cluster management routes
|
||||
cluster := api.Group("/cluster")
|
||||
{
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// Monitoring routes
|
||||
monitoring := api.Group("/monitoring")
|
||||
{
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// Admin auth login endpoint (only if admin is enabled)
|
||||
@@ -233,10 +254,11 @@ func SetupRoutes(
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
@@ -249,7 +271,7 @@ func SetupRoutes(
|
||||
userInfo.Roles = ui.Roles
|
||||
}
|
||||
}
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
if cfg.AccessControl == nil && !authService.IsAdmin(userInfo) {
|
||||
logger.Warn().
|
||||
Str("username", userInfo.Username).
|
||||
Strs("required_roles", adminRoles).
|
||||
@@ -261,6 +283,19 @@ func SetupRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -311,7 +346,8 @@ func SetupRoutes(
|
||||
if strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/auth") ||
|
||||
strings.HasPrefix(path, "/health") ||
|
||||
strings.HasPrefix(path, "/docs") {
|
||||
strings.HasPrefix(path, "/docs") ||
|
||||
path == "/metrics" {
|
||||
logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback")
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/authz"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// newTestAppWithAuthz builds a fully-wired fiber.App via SetupRoutes, reusing
|
||||
// newTestApp's fixture (disabled-policy authz middleware, admin auth enabled
|
||||
// so every /api/v1 route is reachable), and returns the *fiber.App directly
|
||||
// for route-table inspection.
|
||||
func newTestAppWithAuthz(t *testing.T) *fiber.App {
|
||||
t.Helper()
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
})
|
||||
return f.App
|
||||
}
|
||||
|
||||
// TestEveryAPIRouteDeclaresPermission is the CI-level fail-closed guarantee:
|
||||
// a new /api/v1 route without an authz.Require declaration fails this test
|
||||
// (and would also refuse to boot via the same check in main).
|
||||
func TestEveryAPIRouteDeclaresPermission(t *testing.T) {
|
||||
app := newTestAppWithAuthz(t)
|
||||
if err := authz.VerifyRouteCoverage(app); err != nil {
|
||||
t.Fatalf("route coverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newEnabledPolicyFixture builds a SetupRoutes app with an ENABLED
|
||||
// access-control policy (one team, claim "g-t", bucket prefix "allowed-",
|
||||
// permissions bucket.list + object.list + object.read) and returns the
|
||||
// fixture plus a Bearer session token for a member of that team. Admin auth
|
||||
// is enabled so AuthMiddleware accepts the Bearer JWT; the resolver trusts
|
||||
// the signed AuthMethod claim ("oidc"), so the user resolves through the
|
||||
// team policy, not as the synthetic admin.
|
||||
func newEnabledPolicyFixture(t *testing.T) (*routeFixture, string) {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Port: 8080,
|
||||
Environment: "test",
|
||||
},
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{
|
||||
Enabled: true,
|
||||
Username: "admin",
|
||||
Password: "pw",
|
||||
},
|
||||
},
|
||||
CORS: config.CORSConfig{},
|
||||
AccessControl: &config.AccessControlConfig{
|
||||
Teams: []config.TeamConfig{{
|
||||
Name: "team-t",
|
||||
ClaimValues: []string{"g-t"},
|
||||
Bindings: []config.BindingConfig{{
|
||||
BucketPrefixes: []string{"allowed-"},
|
||||
Permissions: []string{"bucket.list", "object.list", "object.read"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService: %v", err)
|
||||
}
|
||||
|
||||
policy, err := authz.CompilePolicy(cfg.AccessControl)
|
||||
if err != nil {
|
||||
t.Fatalf("CompilePolicy: %v", err)
|
||||
}
|
||||
az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer())
|
||||
|
||||
admin := &mocks.AdminMock{}
|
||||
s3 := &mocks.S3Mock{}
|
||||
|
||||
app := fiber.New()
|
||||
SetupRoutes(
|
||||
app,
|
||||
cfg,
|
||||
svc,
|
||||
handlers.NewHealthHandler("test"),
|
||||
handlers.NewBucketHandler(admin, s3),
|
||||
handlers.NewObjectHandler(s3, svc),
|
||||
handlers.NewUserHandler(admin),
|
||||
handlers.NewClusterHandler(admin),
|
||||
handlers.NewMonitoringHandler(admin, s3),
|
||||
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
|
||||
az,
|
||||
)
|
||||
|
||||
token, err := svc.GenerateSessionToken(&auth.UserInfo{
|
||||
Username: "team-user",
|
||||
Email: "team-user@example.com",
|
||||
Teams: []string{"g-t"},
|
||||
AuthMethod: "oidc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
|
||||
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}, token
|
||||
}
|
||||
|
||||
// TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks in the Fiber
|
||||
// behavior the wildcard wiring relies on: the api group's .Use() middlewares
|
||||
// (AuthMiddleware, ResolveSubject) cascade by path prefix onto the wildcard
|
||||
// object routes registered directly on app, so those routes do NOT repeat
|
||||
// ResolveSubject themselves. If the cascade ever stopped covering them, the
|
||||
// allowed-bucket request below would 403 with reason no_subject instead of
|
||||
// reaching the handler.
|
||||
func TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade(t *testing.T) {
|
||||
f, token := newEnabledPolicyFixture(t)
|
||||
|
||||
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil
|
||||
}
|
||||
|
||||
do := func(method, path string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test(%s %s): %v", method, path, err)
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// In-scope bucket + held permission (object.read) → the request passes
|
||||
// Require and reaches the handler. This is the discriminating assertion:
|
||||
// it can only succeed if the group-level ResolveSubject ran for this
|
||||
// wildcard route (no subject → Require denies everything).
|
||||
if code := do("GET", "/api/v1/buckets/allowed-data/objects/somekey"); code != 200 {
|
||||
t.Errorf("GET allowed bucket: status = %d, want 200 (cascaded ResolveSubject + Require allow)", code)
|
||||
}
|
||||
|
||||
// Out-of-scope bucket → default deny.
|
||||
if code := do("GET", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
|
||||
t.Errorf("GET denied bucket: status = %d, want 403", code)
|
||||
}
|
||||
|
||||
// DELETE requires object.delete, which the team does not hold, so it is denied
|
||||
// even on an in-scope bucket.
|
||||
if code := do("DELETE", "/api/v1/buckets/allowed-data/objects/somekey"); code != 403 {
|
||||
t.Errorf("DELETE allowed bucket without object.delete: status = %d, want 403", code)
|
||||
}
|
||||
|
||||
// HEAD wildcard is denied on an out-of-scope bucket too.
|
||||
if code := do("HEAD", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
|
||||
t.Errorf("HEAD denied bucket: status = %d, want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions is the
|
||||
// HTTP-level companion to handlers.TestListBuckets_MapsAliasesAndStats: it
|
||||
// drives GET /api/v1/buckets through the full authz-wired route (not just the
|
||||
// handler in isolation) for a team-scoped session, with the mocked admin
|
||||
// service returning buckets both inside and outside the team's "allowed-"
|
||||
// prefix. Only in-scope buckets should come back, each carrying the caller's
|
||||
// effective_permissions.
|
||||
func TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions(t *testing.T) {
|
||||
f, token := newEnabledPolicyFixture(t)
|
||||
|
||||
f.Admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "id-a", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-a"}},
|
||||
{ID: "id-b", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-b"}},
|
||||
{ID: "id-x", Created: time.Unix(0, 0), GlobalAliases: []string{"denied-x"}},
|
||||
}, nil
|
||||
}
|
||||
f.Admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: alias, Objects: 0, Bytes: 0}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/buckets", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data models.BucketListResponse `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body.Data.Count != 2 {
|
||||
t.Fatalf("count = %d, want 2 (denied-x filtered out): %+v", body.Data.Count, body.Data.Buckets)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, b := range body.Data.Buckets {
|
||||
seen[b.Name] = true
|
||||
if !strings.HasPrefix(b.Name, "allowed-") {
|
||||
t.Errorf("bucket %q returned, want only allowed-* buckets", b.Name)
|
||||
}
|
||||
if len(b.EffectivePermissions) == 0 {
|
||||
t.Errorf("bucket %q: effective_permissions missing", b.Name)
|
||||
}
|
||||
}
|
||||
if !seen["allowed-a"] || !seen["allowed-b"] {
|
||||
t.Errorf("missing expected buckets, got: %+v", body.Data.Buckets)
|
||||
}
|
||||
if seen["denied-x"] {
|
||||
t.Error("denied-x should not be visible to a team without bucket.list on that prefix")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewTokenGrantsObjectGET exercises the full production chain: group
|
||||
// cascade AuthMiddleware accepts the token, ResolveSubject finds no user,
|
||||
// and Require allows via the preview claims instead of a subject.
|
||||
func TestPreviewTokenGrantsObjectGET(t *testing.T) {
|
||||
f, _ := newEnabledPolicyFixture(t)
|
||||
|
||||
// The full-object body echoes the key the handler was actually asked to
|
||||
// serve (the decoded c.Params("*")). Asserting the streamed body equals
|
||||
// the exact key the token was minted for makes any future divergence
|
||||
// between the validated key and the served key fail loudly here rather
|
||||
// than hide behind a constant body.
|
||||
const mintedKey = "media/clip.mp4"
|
||||
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(strings.NewReader(key)), &models.ObjectInfo{Key: key, Size: int64(len(key)), ContentType: "video/mp4", LastModified: time.Now()}, nil
|
||||
}
|
||||
f.S3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
||||
return &models.ObjectInfo{Key: key, Size: 5, ContentType: "video/mp4", LastModified: time.Now()}, nil
|
||||
}
|
||||
f.S3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("ell")), nil
|
||||
}
|
||||
|
||||
token, _, err := f.Auth.MintPreviewToken("allowed-data", mintedKey, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MintPreviewToken: %v", err)
|
||||
}
|
||||
tokenized := "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4?pt=" + url.QueryEscape(token)
|
||||
|
||||
// No Authorization header anywhere in this test.
|
||||
do := func(method, path, rangeHeader string) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test(%s %s): %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
resp := do("GET", tokenized, "")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("tokenized GET: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != mintedKey {
|
||||
t.Errorf("served key = %q, want %q (validated key must equal served key)", body, mintedKey)
|
||||
}
|
||||
|
||||
// Seeking works through the same token.
|
||||
resp = do("GET", tokenized, "bytes=1-3")
|
||||
if resp.StatusCode != 206 {
|
||||
t.Errorf("tokenized ranged GET: status = %d, want 206", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The token never opens the JSON subroutes or other objects.
|
||||
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4%2Fmetadata?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
|
||||
t.Errorf("metadata with token: status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/other.mp4?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
|
||||
t.Errorf("other object with token: status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4", ""); resp.StatusCode != 401 {
|
||||
t.Errorf("no token, no auth: status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/authz"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
)
|
||||
|
||||
// Flag off (default): /metrics is not registered, and the authenticated
|
||||
// /api/v1/monitoring/metrics route still rejects unauthenticated requests.
|
||||
func TestRoutes_MetricsPublic_Disabled_NotRegistered(t *testing.T) {
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
// MetricsPublic defaults to false.
|
||||
})
|
||||
|
||||
expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404)
|
||||
expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401)
|
||||
}
|
||||
|
||||
// Flag on, with admin auth enabled: /metrics serves without credentials, while
|
||||
// the authenticated /api/v1/monitoring/metrics route still requires auth.
|
||||
func TestRoutes_MetricsPublic_Enabled_ServesWithoutAuth(t *testing.T) {
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
c.Auth.MetricsPublic = true
|
||||
})
|
||||
f.Admin.GetMetricsFn = func(_ context.Context) (string, error) {
|
||||
return "garage_metric 1", nil
|
||||
}
|
||||
|
||||
resp := expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 200)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "garage_metric") {
|
||||
t.Errorf("GET /metrics body = %q, want it to contain the metrics text", string(body))
|
||||
}
|
||||
|
||||
// The /api/v1 route stays gated; the fail-closed guarantee is intact.
|
||||
expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401)
|
||||
}
|
||||
|
||||
// Route coverage must still pass with the flag on: /metrics is outside /api/v1,
|
||||
// so VerifyRouteCoverage neither requires a Require handler for it nor errors.
|
||||
func TestRoutes_MetricsPublic_Enabled_RouteCoverageStillPasses(t *testing.T) {
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
c.Auth.MetricsPublic = true
|
||||
})
|
||||
if err := authz.VerifyRouteCoverage(f.App); err != nil {
|
||||
t.Errorf("VerifyRouteCoverage returned error with metrics_public on: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// With the metrics flag OFF and the SPA frontend present, GET /metrics must
|
||||
// return 404 (not the SPA index.html), so a misconfigured Prometheus scrape
|
||||
// fails loudly instead of silently receiving HTML with a 200 status.
|
||||
func TestRoutes_MetricsPublic_Disabled_WithSPA_Returns404(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
// Create ./frontend/dist/index.html so the SPA fallback mounts.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "frontend", "dist", "index.html"),
|
||||
[]byte("<!doctype html><title>spa</title>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
// MetricsPublic defaults to false → no /metrics route registered.
|
||||
})
|
||||
|
||||
// SPA fallback is mounted; /metrics must be excluded from it → 404, not
|
||||
// index.html with 200.
|
||||
expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404)
|
||||
}
|
||||
@@ -103,6 +103,30 @@ func TestRoutes_ObjectWildcard_GET_PresignSuffixRoutesToPresigned(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_ObjectWildcard_GET_PreviewURLSuffixRoutesToPreviewURL(t *testing.T) {
|
||||
f := newNoAuthFixture(t)
|
||||
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/sub/clip.mp4/preview-url", nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.PreviewURLResponse `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// The dispatch trims the /preview-url suffix, so the key becomes sub/clip.mp4,
|
||||
// percent-encoded whole (slash to %2F) in the returned URL, with a pt token.
|
||||
if !strings.Contains(body.Data.URL, "/api/v1/buckets/b1/objects/sub%2Fclip.mp4?pt=") {
|
||||
t.Errorf("url = %q, want the whole-encoded key with a pt token", body.Data.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_ObjectWildcard_DELETE_RoutesToDeleteObject(t *testing.T) {
|
||||
f := newNoAuthFixture(t)
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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"
|
||||
@@ -57,6 +58,12 @@ 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,
|
||||
@@ -64,11 +71,12 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
|
||||
svc,
|
||||
handlers.NewHealthHandler("test"),
|
||||
handlers.NewBucketHandler(admin, s3),
|
||||
handlers.NewObjectHandler(s3),
|
||||
handlers.NewObjectHandler(s3, svc),
|
||||
handlers.NewUserHandler(admin),
|
||||
handlers.NewClusterHandler(admin),
|
||||
handlers.NewMonitoringHandler(admin, s3),
|
||||
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2()),
|
||||
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
|
||||
az,
|
||||
)
|
||||
|
||||
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}
|
||||
@@ -198,6 +206,7 @@ func TestRoutes_AllAPIRoutesRegistered(t *testing.T) {
|
||||
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt"},
|
||||
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/metadata"},
|
||||
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/presign"},
|
||||
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/preview-url"},
|
||||
{"DELETE", "/api/v1/buckets/b1/objects/folder/file.txt"},
|
||||
{"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"},
|
||||
// Users
|
||||
|
||||
@@ -39,8 +39,14 @@ type AdminServiceResult struct {
|
||||
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) {
|
||||
if err := probeEndpoint(cfg, "/v2/GetClusterHealth"); err == nil {
|
||||
// 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{
|
||||
@@ -50,7 +56,17 @@ func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := probeEndpoint(cfg, "/v1/health"); err == 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")
|
||||
@@ -68,6 +84,26 @@ func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceRe
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -86,6 +122,9 @@ func probeEndpoint(cfg *config.GarageConfig, path string) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -89,3 +89,61 @@ func TestDetectVersion_Unreachable(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,14 +48,17 @@ type AdminService interface {
|
||||
// GetBucketStatistics) are intentionally excluded.
|
||||
type S3Storage interface {
|
||||
ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
|
||||
SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error)
|
||||
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
|
||||
CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
|
||||
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
||||
GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error)
|
||||
ObjectExists(ctx context.Context, bucketName, key string) (bool, error)
|
||||
DeleteObject(ctx context.Context, bucketName, key string) error
|
||||
GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
|
||||
GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
|
||||
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error
|
||||
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error)
|
||||
DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error)
|
||||
UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestS3Mock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
|
||||
if _, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err == nil {
|
||||
t.Error("GetPresignedURL: want error")
|
||||
}
|
||||
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
|
||||
if _, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
|
||||
t.Error("DeleteMultipleObjects: want error")
|
||||
}
|
||||
// UploadMultipleObjects has no error channel; it must return a result slice
|
||||
@@ -163,7 +163,7 @@ func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
|
||||
GetPresignedURLFn: func(_ context.Context, _, _ string, _ time.Duration) (string, error) {
|
||||
return "http://x", nil
|
||||
},
|
||||
DeleteMultipleObjectsFn: func(_ context.Context, _ string, _ []string) error { return nil },
|
||||
DeleteMultipleObjectsFn: func(_ context.Context, _ string, keys []string) (int, error) { return len(keys), nil },
|
||||
UploadMultipleObjectsFn: func(_ context.Context, _ string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
@@ -201,8 +201,8 @@ func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
|
||||
if u, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err != nil || u == "" {
|
||||
t.Errorf("GetPresignedURL = (%q, %v)", u, err)
|
||||
}
|
||||
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil {
|
||||
t.Errorf("DeleteMultipleObjects: %v", err)
|
||||
if n, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil || n != 1 {
|
||||
t.Errorf("DeleteMultipleObjects = (%d, %v)", n, err)
|
||||
}
|
||||
results := m.UploadMultipleObjects(ctx, "b", []struct {
|
||||
Key string
|
||||
|
||||
@@ -23,15 +23,18 @@ 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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error
|
||||
GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
||||
GetObjectRangeFn func(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error)
|
||||
ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error)
|
||||
DeleteObjectFn func(ctx context.Context, bucketName, key string) error
|
||||
GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
|
||||
GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
|
||||
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) (int, error)
|
||||
DeleteObjectsByPrefixFn func(ctx context.Context, bucketName, prefix string) (int, error)
|
||||
UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
@@ -55,6 +58,14 @@ 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 {
|
||||
@@ -79,6 +90,14 @@ func (m *S3Mock) GetObject(ctx context.Context, bucketName, key string) (io.Read
|
||||
return m.GetObjectFn(ctx, bucketName, key)
|
||||
}
|
||||
|
||||
func (m *S3Mock) GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error) {
|
||||
m.record("GetObjectRange", bucketName, key)
|
||||
if m.GetObjectRangeFn == nil {
|
||||
return nil, s3NotConfigured("GetObjectRange")
|
||||
}
|
||||
return m.GetObjectRangeFn(ctx, bucketName, key, start, end)
|
||||
}
|
||||
|
||||
func (m *S3Mock) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
|
||||
m.record("ObjectExists", bucketName, key)
|
||||
if m.ObjectExistsFn == nil {
|
||||
@@ -111,14 +130,22 @@ func (m *S3Mock) GetPresignedURL(ctx context.Context, bucketName, key string, ex
|
||||
return m.GetPresignedURLFn(ctx, bucketName, key, expiresIn)
|
||||
}
|
||||
|
||||
func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
|
||||
func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error) {
|
||||
m.record("DeleteMultipleObjects", bucketName, keys)
|
||||
if m.DeleteMultipleObjectsFn == nil {
|
||||
return s3NotConfigured("DeleteMultipleObjects")
|
||||
return 0, s3NotConfigured("DeleteMultipleObjects")
|
||||
}
|
||||
return m.DeleteMultipleObjectsFn(ctx, bucketName, keys)
|
||||
}
|
||||
|
||||
func (m *S3Mock) DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error) {
|
||||
m.record("DeleteObjectsByPrefix", bucketName, prefix)
|
||||
if m.DeleteObjectsByPrefixFn == nil {
|
||||
return 0, s3NotConfigured("DeleteObjectsByPrefix")
|
||||
}
|
||||
return m.DeleteObjectsByPrefixFn(ctx, bucketName, prefix)
|
||||
}
|
||||
|
||||
func (m *S3Mock) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
|
||||
+181
-17
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -240,17 +241,10 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, 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)
|
||||
}
|
||||
@@ -331,6 +325,97 @@ 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
|
||||
@@ -439,6 +524,34 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
|
||||
return object, objectInfo, nil
|
||||
}
|
||||
|
||||
// GetObjectRange retrieves an inclusive byte range of an object. The caller
|
||||
// resolves the range against the object size beforehand, so this method does
|
||||
// not stat the object again.
|
||||
func (s *S3Service) GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error) {
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
opts := minio.GetObjectOptions{}
|
||||
if err := opts.SetRange(start, end); err != nil {
|
||||
return nil, fmt.Errorf("invalid range %d-%d for object %s: %w", start, end, key, err)
|
||||
}
|
||||
|
||||
var object *minio.Object
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var getErr error
|
||||
object, getErr = client.GetObject(ctx, bucketName, key, opts)
|
||||
return getErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
return object, nil
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
|
||||
// Get bucket-specific MinIO client
|
||||
@@ -519,16 +632,23 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteMultipleObjects deletes multiple objects from a bucket
|
||||
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
|
||||
// DeleteMultipleObjects deletes multiple objects from a bucket and returns the
|
||||
// number of objects that were removed (requested keys minus any that failed).
|
||||
//
|
||||
// Note: S3/MinIO batch delete is idempotent — removing a key that does not
|
||||
// exist succeeds and is not reported on the error channel, so it counts toward
|
||||
// the returned total. The count therefore reflects "keys the delete operation
|
||||
// did not fail on", which is the strongest signal obtainable without a
|
||||
// per-key existence check.
|
||||
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
return 0, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Create channel for objects to delete
|
||||
@@ -544,17 +664,61 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
|
||||
}
|
||||
}()
|
||||
|
||||
// Call MinIO RemoveObjects API (batch delete)
|
||||
// Call MinIO RemoveObjects API (batch delete). RemoveObjects only surfaces
|
||||
// the objects it FAILED to delete, so we drain the whole channel (which also
|
||||
// avoids leaking the sender goroutine) and count failures.
|
||||
errorCh := client.RemoveObjects(ctx, bucketName, objectsCh, minio.RemoveObjectsOptions{})
|
||||
|
||||
// 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)
|
||||
failed := 0
|
||||
var firstErr error
|
||||
for rerr := range errorCh {
|
||||
if rerr.Err != nil {
|
||||
failed++
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("failed to delete object %s from bucket %s: %w", rerr.ObjectName, bucketName, rerr.Err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
if firstErr != nil {
|
||||
return len(keys) - failed, firstErr
|
||||
}
|
||||
|
||||
return len(keys), nil
|
||||
}
|
||||
|
||||
// DeleteObjectsByPrefix recursively deletes every object stored under the given
|
||||
// prefix (i.e. a "folder"), including the directory marker itself. It returns
|
||||
// the number of objects that were deleted.
|
||||
func (s *S3Service) DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error) {
|
||||
if prefix == "" {
|
||||
return 0, fmt.Errorf("prefix is required for recursive delete")
|
||||
}
|
||||
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// List every object under the prefix recursively (no delimiter), so nested
|
||||
// folders are flattened into their concrete keys.
|
||||
keys := make([]string, 0)
|
||||
for obj := range client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{
|
||||
Prefix: prefix,
|
||||
Recursive: true,
|
||||
}) {
|
||||
if obj.Err != nil {
|
||||
return 0, fmt.Errorf("failed to list objects under prefix %s in bucket %s: %w", prefix, bucketName, obj.Err)
|
||||
}
|
||||
keys = append(keys, obj.Key)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return s.DeleteMultipleObjects(ctx, bucketName, keys)
|
||||
}
|
||||
|
||||
// GetPresignedURL generates a pre-signed URL for temporary access to an object
|
||||
|
||||
@@ -274,8 +274,8 @@ func TestS3_DeleteMultipleObjects_EmptyKeysIsNoop(t *testing.T) {
|
||||
})
|
||||
s3 := newS3TestService(t, h)
|
||||
|
||||
if err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil {
|
||||
t.Fatalf("empty keys should return nil, got %v", err)
|
||||
if n, err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil || n != 0 {
|
||||
t.Fatalf("empty keys should return (0, nil), got (%d, %v)", n, err)
|
||||
}
|
||||
if called {
|
||||
t.Error("S3 handler was invoked for empty-keys call")
|
||||
@@ -289,12 +289,114 @@ func TestS3_DeleteMultipleObjects_ServerErrorPropagates(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
|
||||
_, err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// s3PrefixDeleteHandler serves a ListObjectsV2 response (GET) from listBody and
|
||||
// a successful multi-object DeleteResult (POST /{bucket}?delete), recording how
|
||||
// many batch-delete requests were made.
|
||||
func s3PrefixDeleteHandler(listBody string, deletePosts *int) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
*deletePosts++
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?><DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`)
|
||||
return
|
||||
}
|
||||
// Any GET is treated as a ListObjectsV2 request.
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, listBody)
|
||||
})
|
||||
}
|
||||
|
||||
func TestS3_DeleteObjectsByPrefix_EmptyPrefixIsError(t *testing.T) {
|
||||
// A blank prefix must be rejected before any network call — it would
|
||||
// otherwise match (and delete) every object in the bucket.
|
||||
called := false
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
s3ErrorXML(w, http.StatusInternalServerError, "ShouldNotHappen", "")
|
||||
})
|
||||
s3 := newS3TestService(t, h)
|
||||
|
||||
n, err := s3.DeleteObjectsByPrefix(context.Background(), "b-TestS3_DeleteObjectsByPrefix_EmptyPrefixIsError", "")
|
||||
if err == nil {
|
||||
t.Fatal("empty prefix should return an error")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("count = %d, want 0", n)
|
||||
}
|
||||
if called {
|
||||
t.Error("no S3 request should be made for an empty prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_DeleteObjectsByPrefix_ListsThenDeletes(t *testing.T) {
|
||||
contents := []struct {
|
||||
Key string
|
||||
Size int64
|
||||
LastModified string
|
||||
ETag string
|
||||
}{
|
||||
{Key: "docs/a"}, {Key: "docs/b"}, {Key: "docs/sub/c"},
|
||||
}
|
||||
listBody := listBucketResultXML("b", false, "", contents, nil)
|
||||
deletePosts := 0
|
||||
s3 := newS3TestService(t, s3PrefixDeleteHandler(listBody, &deletePosts))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
n, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_ListsThenDeletes", "docs/")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteObjectsByPrefix: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("deleted = %d, want 3 (all objects listed under the prefix)", n)
|
||||
}
|
||||
if deletePosts == 0 {
|
||||
t.Error("expected a batch-delete request to be made")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_DeleteObjectsByPrefix_NoObjectsReturnsZero(t *testing.T) {
|
||||
listBody := listBucketResultXML("b", false, "", nil, nil)
|
||||
deletePosts := 0
|
||||
s3 := newS3TestService(t, s3PrefixDeleteHandler(listBody, &deletePosts))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
n, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_NoObjectsReturnsZero", "empty/")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteObjectsByPrefix: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("deleted = %d, want 0", n)
|
||||
}
|
||||
if deletePosts != 0 {
|
||||
t.Errorf("no batch-delete should be made when nothing matches, got %d", deletePosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_DeleteObjectsByPrefix_ListErrorPropagates(t *testing.T) {
|
||||
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
|
||||
s3 := newS3TestService(t, h)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_ListErrorPropagates", "docs/")
|
||||
if err == nil {
|
||||
t.Fatal("expected the list error to propagate, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_GetPresignedURL_ReturnsURLWithoutServerCall(t *testing.T) {
|
||||
// Presign is purely local (no network round-trip). Any handler suffices.
|
||||
called := false
|
||||
@@ -567,3 +669,69 @@ func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectRange_SendsRangeHeaderAndStreamsBody(t *testing.T) {
|
||||
bucket := uniqueBucket2(t)
|
||||
var gotRange string
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotRange = r.Header.Get("Range")
|
||||
w.Header().Set("Content-Range", "bytes 2-6/10")
|
||||
w.Header().Set("Content-Length", "5")
|
||||
// The MinIO client parses Last-Modified from the response headers on
|
||||
// the first Read, so the fake server must set a valid one.
|
||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("23456"))
|
||||
})
|
||||
s3 := newS3TestService(t, handler)
|
||||
|
||||
body, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 2, 6)
|
||||
if err != nil {
|
||||
t.Fatalf("GetObjectRange: %v", err)
|
||||
}
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if string(data) != "23456" {
|
||||
t.Errorf("body = %q, want %q", data, "23456")
|
||||
}
|
||||
if gotRange != "bytes=2-6" {
|
||||
t.Errorf("Range header = %q, want %q", gotRange, "bytes=2-6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectRange_InvalidRangeRejectedLocally(t *testing.T) {
|
||||
bucket := uniqueBucket2(t)
|
||||
var called bool
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
})
|
||||
s3 := newS3TestService(t, handler)
|
||||
|
||||
// end before start is a caller bug; SetRange rejects it before any request.
|
||||
if _, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 6, 2); err == nil {
|
||||
t.Fatal("expected error for inverted range")
|
||||
}
|
||||
if called {
|
||||
t.Error("no S3 request should be sent for an invalid range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectRange_ClientAcquisitionFailurePropagates(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := s3.GetObjectRange(ctx, bucket, "missing", 0, 4); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -115,7 +116,7 @@ func main() {
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to Garage admin API")
|
||||
}
|
||||
adminService := adminResult.Service
|
||||
capabilitiesHandler := handlers.NewCapabilitiesHandler(adminResult.APIVersion, adminResult.Capabilities)
|
||||
capabilitiesHandler := handlers.NewCapabilitiesHandler(adminResult.APIVersion, adminResult.Capabilities, cfg.AccessControl != nil)
|
||||
|
||||
logger.Info().Msg("Initializing S3 service")
|
||||
s3Service := services.NewS3Service(&cfg.Garage, adminService)
|
||||
@@ -140,10 +141,19 @@ func main() {
|
||||
logger.Fatal().Err(err).Msg("Failed to initialize auth service")
|
||||
}
|
||||
|
||||
policy, err := authz.CompilePolicy(cfg.AccessControl)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid access_control configuration")
|
||||
}
|
||||
if cfg.AccessControl != nil && !cfg.Auth.OIDC.Enabled {
|
||||
logger.Warn().Msg("access_control is configured but OIDC is disabled: admin and token logins are always full-admin in v1, so the policy currently gates nothing")
|
||||
}
|
||||
azMiddleware := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, cfg.Auth.OIDC.EffectiveAdminRoles()), authz.NewAuthorizer())
|
||||
|
||||
// Initialize handlers
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
|
||||
objectHandler := handlers.NewObjectHandler(s3Service)
|
||||
objectHandler := handlers.NewObjectHandler(s3Service, authService)
|
||||
userHandler := handlers.NewUserHandler(adminService)
|
||||
clusterHandler := handlers.NewClusterHandler(adminService)
|
||||
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
|
||||
@@ -212,8 +222,13 @@ func main() {
|
||||
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()
|
||||
|
||||
@@ -10,6 +10,7 @@ coverage:
|
||||
|
||||
ignore:
|
||||
- "backend/main.go"
|
||||
- "backend/cmd/"
|
||||
- "backend/docs/"
|
||||
- "backend/internal/services/mocks/"
|
||||
- "backend/**/*_mock.go"
|
||||
|
||||
@@ -34,6 +34,12 @@ auth:
|
||||
# The key is a 64-byte Ed25519 private key
|
||||
jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key
|
||||
|
||||
# Expose Prometheus metrics at top-level /metrics WITHOUT authentication.
|
||||
# Needed for Prometheus to scrape when auth (admin/token/oidc) is enabled.
|
||||
# WARNING: exposes operational cluster telemetry (no object data or secrets)
|
||||
# to anyone who can reach the port. Restrict with a NetworkPolicy / firewall.
|
||||
metrics_public: false # Set to true to serve /metrics unauthenticated
|
||||
|
||||
# Admin Authentication (username/password)
|
||||
admin:
|
||||
enabled: false # Set to true to enable admin login
|
||||
@@ -75,6 +81,8 @@ 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
|
||||
@@ -94,6 +102,29 @@ auth:
|
||||
cookie_http_only: true
|
||||
cookie_same_site: "lax" # lax, strict, none
|
||||
|
||||
# Optional: team-based access control (see docs/access-control.md).
|
||||
# Absent -> every authenticated user has full access.
|
||||
# Present -> default-deny: OIDC users get only what their teams grant; users
|
||||
# matching no team get 403 everywhere. admin_role users, admin
|
||||
# password logins, and token logins are always full-admin.
|
||||
# NOTE: this is UI-layer policy, NOT a security boundary. Anyone holding the
|
||||
# Garage admin token or S3 keys bypasses it entirely.
|
||||
#
|
||||
# access_control:
|
||||
# presets:
|
||||
# bucket_readonly: [bucket.list, bucket.read, object.list, object.read]
|
||||
# bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update,
|
||||
# bucket.delete, object.write, object.delete]
|
||||
# teams:
|
||||
# - name: backend
|
||||
# claim_values: ["garage-team-backend"] # matched against team_attribute_path claim
|
||||
# bindings:
|
||||
# - bucket_prefixes: ["backend-"]
|
||||
# permissions: ["preset:bucket_owner"]
|
||||
# - bucket_prefixes: ["shared-"]
|
||||
# permissions: ["preset:bucket_readonly"]
|
||||
# cluster_permissions: [cluster.status, cluster.health]
|
||||
|
||||
# CORS Configuration (for frontend)
|
||||
cors:
|
||||
enabled: true
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Multi-User Access Control
|
||||
|
||||
Garage UI can scope what each user sees and does, based on the teams in their OIDC claims. A typical setup: the backend team manages every `backend-*` bucket, can read the shared ones, and sees nothing else.
|
||||
|
||||
This is optional. If your config has no `access_control` section, every authenticated user has full access.
|
||||
|
||||
## Not a security boundary
|
||||
|
||||
Read this before relying on access control for anything sensitive.
|
||||
|
||||
Garage UI talks to Garage with a single admin token and a single set of S3 keys. Access control is enforced by the UI, not by Garage. Anyone who holds the underlying admin token or raw S3 keys bypasses it completely.
|
||||
|
||||
Use it to give each team a convenient, scoped view of the cluster. Don't use it as a substitute for real per-tenant credentials or network isolation.
|
||||
|
||||
## Setup
|
||||
|
||||
Get OIDC login working first. Access control only applies to OIDC users, and [config.example.yaml](../config.example.yaml) covers the OIDC settings.
|
||||
|
||||
From there, it takes two additions to your config:
|
||||
|
||||
1. `team_attribute_path` tells Garage UI which OIDC claim lists a user's teams.
|
||||
2. `access_control` maps those teams to permissions.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
oidc:
|
||||
# ...your existing OIDC settings...
|
||||
team_attribute_path: "groups"
|
||||
|
||||
access_control:
|
||||
presets:
|
||||
bucket_readonly: [bucket.list, bucket.read, object.list, object.read]
|
||||
bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update,
|
||||
bucket.delete, object.write, object.delete]
|
||||
teams:
|
||||
- name: backend
|
||||
claim_values: ["garage-team-backend"]
|
||||
bindings:
|
||||
- bucket_prefixes: ["backend-"]
|
||||
permissions: ["preset:bucket_owner"]
|
||||
- bucket_prefixes: ["shared-"]
|
||||
permissions: ["preset:bucket_readonly"]
|
||||
cluster_permissions: [cluster.status, cluster.health]
|
||||
```
|
||||
|
||||
Reading the example top to bottom:
|
||||
|
||||
- `presets` are named permission bundles you can reuse across teams. They're optional; you can always list permissions directly.
|
||||
- A user whose `groups` claim contains `garage-team-backend` lands in the `backend` team.
|
||||
- That team owns buckets starting with `backend-`, can read buckets starting with `shared-`, and can view cluster status and health.
|
||||
- Everyone else, including OIDC users who match no team, gets a 403 on everything. The moment `access_control` exists, the UI switches to default-deny.
|
||||
|
||||
A few things to know before writing your own:
|
||||
|
||||
- `team_attribute_path` is a [go-jmespath](https://github.com/jmespath/go-jmespath) expression evaluated against the OIDC claims, the same convention as `role_attribute_path`. It's required whenever `access_control.teams` is set and OIDC is enabled. If it's missing, the server refuses to start and the error says why.
|
||||
- `access_control` can only be set in the config file. There's no environment variable for it, because nested team and binding lists don't fit flat `GARAGE_UI_*` variables.
|
||||
- If `access_control` is present but OIDC is disabled, the server still starts but logs a warning. The policy would gate nothing, since admin-password and token logins are always full admin (see [Admins](#admins)).
|
||||
|
||||
### Check that it works
|
||||
|
||||
Log in as a test user and open `GET /api/v1/capabilities`. The `access_control` block in the response shows the user's resolved `bindings` and `cluster_permissions`. Empty arrays mean the user matched no team; [Troubleshooting](#troubleshooting) covers the usual reasons.
|
||||
|
||||
## How permissions are resolved
|
||||
|
||||
### Default-deny
|
||||
|
||||
Once `access_control` is set, an OIDC user who matches no team gets a 403 on every `/api/v1` endpoint. The one exception is `GET /api/v1/capabilities`, which returns their (empty) permissions so the frontend can show a "no access" screen.
|
||||
|
||||
### Union of teams
|
||||
|
||||
A user who matches several teams gets everything those teams grant.
|
||||
|
||||
Bindings stay separate, though. Say one binding grants `read` on `backend-*` and another grants `write` on `data-*`. The user does not end up with both permissions on both prefixes. Each binding keeps its own prefixes and its own permissions.
|
||||
|
||||
### Prefix match
|
||||
|
||||
`bucket_prefixes` are plain string prefixes on bucket names, with no globbing on the name itself. Use `"*"` to match every bucket.
|
||||
|
||||
### Presets
|
||||
|
||||
Reference a preset with the `preset:` prefix inside any `permissions` or `cluster_permissions` list, for example `"preset:bucket_owner"`. Presets can reference other presets. Unknown references and cycles both fail startup.
|
||||
|
||||
### Permission globs
|
||||
|
||||
A trailing-star glob like `bucket.*`, `object.*`, or `cluster.layout.*` expands against the permission vocabulary when the config loads. Keep globs inside their scope:
|
||||
|
||||
- `bucket.*` and `object.*` go in a binding's `permissions`
|
||||
- `cluster.*`, `node.*`, `worker.*`, and `block.*` go under `cluster_permissions`
|
||||
|
||||
A bare `*` is technically a glob, but it almost always fails validation: it mixes prefix-scoped and global-scoped permissions, and a permission placed in the wrong scope is rejected at startup. Globs never expand to admin-only permissions, and there's no way to grant those to a team.
|
||||
|
||||
### Admins
|
||||
|
||||
These identities become a synthetic admin:
|
||||
|
||||
- OIDC users holding a configured `admin_role` / `admin_roles`
|
||||
- all non-OIDC logins (admin password, Garage admin token)
|
||||
|
||||
An admin gets every permission on every bucket, plus every cluster permission. Admins go through the same authorizer as any team; there's no `IsAdmin` shortcut that skips the check.
|
||||
|
||||
### Startup validation
|
||||
|
||||
The server refuses to start when the policy is invalid: an unknown permission, an unknown or cyclic preset, an admin-only permission granted to a team, a duplicate team name, a team with empty `claim_values`, or a team with no bindings and no cluster permissions.
|
||||
|
||||
It also refuses to start if any `/api/v1` route has no declared permission, so a route can never ship un-gated (see [Troubleshooting](#troubleshooting)).
|
||||
|
||||
## Current limitations
|
||||
|
||||
- **Only OIDC users can be scoped to a team.** Admin-password and Garage-admin-token logins are always full admin.
|
||||
- **`ListKeys` is not filtered.** Anyone with `key.list` sees every access key. Everything past `key.list` and `key.read` is admin-only: `key.read_secret`, `key.create`, `key.import`, `key.update`, `key.delete`.
|
||||
- **No `admin_token.*` permissions.** Direct access to the raw Garage admin token is admin-only and not part of the vocabulary.
|
||||
- **No ABAC, policy language, database-backed policy, or per-user grants.** Policy is YAML, compiled once at startup.
|
||||
|
||||
## Permission reference
|
||||
|
||||
Permission names are lowercase and dot-separated: two segments, or three for `cluster.layout.*`. The source of truth is `backend/internal/authz/vocabulary.go`; this table is maintained by hand, so update it whenever the registry changes.
|
||||
|
||||
| Permission | Scope | Admin-only | Garage endpoint / backing |
|
||||
|---|---|---|---|
|
||||
| `bucket.list` | prefix | | ListBuckets (response-filtered) |
|
||||
| `bucket.read` | prefix | | GetBucketInfo |
|
||||
| `bucket.create` | prefix | | CreateBucket (new name must match a prefix) |
|
||||
| `bucket.update` | prefix | | UpdateBucket |
|
||||
| `bucket.delete` | prefix | | DeleteBucket |
|
||||
| `bucket.cleanup_uploads` | prefix | | CleanupIncompleteUploads |
|
||||
| `bucket.inspect_object` | prefix | | InspectObject |
|
||||
| `bucket_alias.add` | prefix | | AddBucketAlias |
|
||||
| `bucket_alias.remove` | prefix | | RemoveBucketAlias |
|
||||
| `object.list` | prefix | | S3 data plane (ListObjects) |
|
||||
| `object.read` | prefix | | S3 data plane (Get/Head/Metadata/Presign; presign is download-only) |
|
||||
| `object.write` | prefix | | S3 data plane (Upload, CreateDirectory) |
|
||||
| `object.delete` | prefix | | S3 data plane (Delete, DeleteMultiple) |
|
||||
| `permission.allow_bucket_key` | prefix | | AllowBucketKey |
|
||||
| `permission.deny_bucket_key` | prefix | | DenyBucketKey |
|
||||
| `key.list` | global | | ListKeys (unfiltered; grantee sees all keys) |
|
||||
| `key.read` | global | | GetKeyInfo (without secret) |
|
||||
| `key.read_secret` | global | yes | GetKeyInfo with secret material |
|
||||
| `key.create` | global | yes | CreateKey |
|
||||
| `key.import` | global | yes | ImportKey |
|
||||
| `key.update` | global | yes | UpdateKey |
|
||||
| `key.delete` | global | yes | DeleteKey |
|
||||
| `cluster.status` | global | | GetClusterStatus |
|
||||
| `cluster.health` | global | | GetClusterHealth |
|
||||
| `cluster.statistics` | global | | GetClusterStatistics |
|
||||
| `cluster.connect_nodes` | global | | ConnectClusterNodes |
|
||||
| `cluster.layout.read` | global | | GetClusterLayout |
|
||||
| `cluster.layout.history` | global | | GetClusterLayoutHistory |
|
||||
| `cluster.layout.apply` | global | | ApplyClusterLayout |
|
||||
| `cluster.layout.skip_dead_nodes` | global | | ClusterLayoutSkipDeadNodes |
|
||||
| `node.info` | global | | GetNodeInfo |
|
||||
| `node.statistics` | global | | GetNodeStatistics |
|
||||
| `node.snapshot` | global | | CreateMetadataSnapshot |
|
||||
| `node.repair` | global | | LaunchRepairOperation |
|
||||
| `worker.list` | global | | ListWorkers |
|
||||
| `worker.info` | global | | GetWorkerInfo |
|
||||
| `worker.get_variable` | global | | GetWorkerVariable |
|
||||
| `worker.set_variable` | global | | SetWorkerVariable |
|
||||
| `block.list_errors` | global | | ListBlockErrors |
|
||||
| `block.info` | global | | GetBlockInfo |
|
||||
|
||||
Some permissions have no UI route yet: `bucket.cleanup_uploads`, `bucket.inspect_object`, `bucket_alias.*`, `cluster.layout.*`, `worker.*`, `block.*`, `cluster.connect_nodes`, `node.snapshot`, `node.repair`, and `key.import`. They're valid in config but don't gate anything in the UI so far. The vocabulary is complete up front so your config keeps working as the UI grows.
|
||||
|
||||
Dangerous operations (`cluster.layout.apply`, `node.repair`, `worker.set_variable`) are separate, individually grantable permissions. They're never bundled into a read-only preset, so you can give a team cluster visibility without also giving it the power to break the cluster.
|
||||
|
||||
One endpoint needs two permissions: `POST /api/v1/buckets/:name/permissions` performs an allow and a deny in a single call, so it requires both `permission.allow_bucket_key` and `permission.deny_bucket_key`. One of the two alone is not enough.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **A user gets 403s they shouldn't.** Open `GET /api/v1/capabilities` while logged in as that user. The `access_control` block shows their resolved `bindings` and `cluster_permissions` (empty arrays mean they matched no team). Check that the IdP actually sends the claim named by `team_attribute_path`, and that its values match a team's `claim_values` exactly. It's a plain string match, with no wildcards on the claim value.
|
||||
|
||||
- **403 responses name the missing permission.** The message is `Missing permission: <permission.name>`. That's the exact permission that was denied, so you know which binding, preset, or `cluster_permissions` entry to add.
|
||||
|
||||
- **Decision logs.** Every check logs one line, `authz_decision`, with fields `subject`, `action`, `resource`, `decision` (`allow` / `deny`), and `reason` (such as `binding_match`, `any_binding`, `no_matching_binding`, `cluster_permission`, `no_cluster_permission`, `no_subject`). Denials log at `warn`, allows at `debug`. Set `logging.level` to `debug` to see successful checks too. There's no separate audit log; this goes through the normal application logger.
|
||||
|
||||
- **Startup fails with `access_control: ...` or `authz: routes without Require permission declaration: ...`.** Both are intentional fail-closed checks, not bugs:
|
||||
- An invalid policy (unknown permission, bad preset reference, admin-only permission handed to a team, duplicate team name, empty `claim_values`, or a team with no bindings or cluster permissions) stops startup with an error naming the problem.
|
||||
- A `/api/v1` route wired without a permission requirement also stops startup. This is a safety net for developers, not something you trigger by editing config, but it can show up after a `git pull` that adds a route without its enforcement wiring.
|
||||
|
||||
## See also
|
||||
|
||||
- [config.example.yaml](../config.example.yaml): the full commented `access_control` example.
|
||||
- [garage-setup.md](garage-setup.md): general Garage UI and Garage setup.
|
||||
Generated
+1898
-826
File diff suppressed because it is too large
Load Diff
+14
-5
@@ -7,23 +7,27 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.16.0",
|
||||
"axios": "^1.18.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -33,19 +37,24 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.3.2"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 {
|
||||
@@ -35,6 +36,7 @@ export function BucketListView({
|
||||
onDeleteBucket,
|
||||
onWebsiteSettings,
|
||||
}: BucketListViewProps) {
|
||||
const canBucket = useBucketCan();
|
||||
const filteredBuckets = buckets.filter((bucket) =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
@@ -118,31 +120,39 @@ export function BucketListView({
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
View Objects
|
||||
</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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Database } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle, Check, Database, ShieldCheck, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
@@ -12,18 +13,67 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { CredentialField } from '@/components/ui/credential-field';
|
||||
import type { CreateBucketResult, KeyPermissions, NewKeyRequest } from '@/lib/create-bucket-with-key';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreateBucketDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateBucket: (name: string) => Promise<boolean>;
|
||||
onCreateBucket: (name: string, key?: NewKeyRequest) => Promise<CreateBucketResult>;
|
||||
canCreateKey: boolean;
|
||||
}
|
||||
|
||||
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
|
||||
const [bucketName, setBucketName] = useState('');
|
||||
const DEFAULT_PERMISSIONS: KeyPermissions = { read: true, write: true, owner: false };
|
||||
|
||||
useEffect(() => { if (!open) setBucketName(''); }, [open]);
|
||||
const PERMISSION_ROWS = [
|
||||
{ field: 'read', label: 'Read', desc: 'GetObject, HeadObject, ListObjects' },
|
||||
{ field: 'write', label: 'Write', desc: 'PutObject, DeleteObject' },
|
||||
{ field: 'owner', label: 'Owner', desc: 'DeleteBucket, PutBucketPolicy' },
|
||||
] as const;
|
||||
|
||||
function StatusRow({ ok, children }: { ok: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<li className="flex items-start gap-2 text-[13.5px]">
|
||||
{ok ? (
|
||||
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--primary)]" />
|
||||
) : (
|
||||
<X className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="flex-1">{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateBucketDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreateBucket,
|
||||
canCreateKey,
|
||||
}: CreateBucketDialogProps) {
|
||||
const [bucketName, setBucketName] = useState('');
|
||||
const [withKey, setWithKey] = useState(false);
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [permissions, setPermissions] = useState<KeyPermissions>(DEFAULT_PERMISSIONS);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [result, setResult] = useState<CreateBucketResult | null>(null);
|
||||
|
||||
// Closing resets the form here rather than in an effect on `open`: Cancel,
|
||||
// Done, the close button, the backdrop and Escape all route through Dialog's
|
||||
// onOpenChange, so this is the single place a close can happen.
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
setBucketName('');
|
||||
setWithKey(false);
|
||||
setKeyName('');
|
||||
setPermissions(DEFAULT_PERMISSIONS);
|
||||
setCreating(false);
|
||||
setResult(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const derivedKeyName = `${bucketName || 'my-bucket'}-key`;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!bucketName) {
|
||||
@@ -31,15 +81,96 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onCreateBucket(bucketName);
|
||||
if (success) {
|
||||
setBucketName('');
|
||||
onOpenChange(false);
|
||||
setCreating(true);
|
||||
try {
|
||||
const outcome = await onCreateBucket(
|
||||
bucketName,
|
||||
withKey && canCreateKey ? { name: keyName || derivedKeyName, permissions } : undefined,
|
||||
);
|
||||
|
||||
// Which panel to show follows from the outcome, not from the form state.
|
||||
if (outcome.bucket === 'failed') return; // the axios interceptor already toasted
|
||||
if (outcome.key || outcome.keyError) {
|
||||
setResult(outcome);
|
||||
return;
|
||||
}
|
||||
handleOpenChange(false);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (result) {
|
||||
const degraded = !!result.keyError || !!result.grantError;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} size="form">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<IconTile
|
||||
icon={degraded ? <AlertTriangle /> : <ShieldCheck />}
|
||||
tone="primary"
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle>{result.key ? 'Bucket and key created' : 'Bucket created'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{result.key
|
||||
? 'Copy your secret access key now, this is the only time it will be shown.'
|
||||
: 'The bucket is ready, but the access key was not created.'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-5">
|
||||
<ul className="space-y-2">
|
||||
<StatusRow ok>
|
||||
Bucket <span className="font-mono">{bucketName}</span> created
|
||||
</StatusRow>
|
||||
{result.key ? (
|
||||
<StatusRow ok>
|
||||
Access key <span className="font-mono">{result.key.name}</span> created
|
||||
</StatusRow>
|
||||
) : (
|
||||
<StatusRow ok={false}>
|
||||
Access key could not be created. You can create one from Access control.
|
||||
</StatusRow>
|
||||
)}
|
||||
{result.key && (
|
||||
<StatusRow ok={!result.grantError}>
|
||||
{result.grantError
|
||||
? 'Permissions were not applied on the bucket. Grant them from Access control.'
|
||||
: 'Permissions granted on the bucket'}
|
||||
</StatusRow>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{result.key && (
|
||||
<>
|
||||
<CredentialField label="Access Key ID" value={result.key.accessKeyId} breakAll />
|
||||
<CredentialField label="Secret Access Key" value={result.key.secretKey} breakAll />
|
||||
<div className="flex gap-3 rounded-lg border border-[var(--accent-primary-border)] bg-[var(--accent-primary-soft)] px-3.5 py-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--primary)]" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[13.5px] font-medium text-[var(--foreground)]">
|
||||
Save this key now
|
||||
</p>
|
||||
<p className="text-[12.5px] leading-[1.5] text-[var(--muted-foreground)]">
|
||||
The secret access key cannot be retrieved again. If lost, you'll need to create a new key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => handleOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} size="form">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<IconTile icon={<Database />} tone="primary" size="md" />
|
||||
@@ -50,10 +181,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-4">
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Bucket Name</label>
|
||||
<label htmlFor="new-bucket-name" className="text-sm font-medium">
|
||||
Bucket Name
|
||||
</label>
|
||||
<Input
|
||||
id="new-bucket-name"
|
||||
autoFocus
|
||||
placeholder="my-bucket-name"
|
||||
value={bucketName}
|
||||
@@ -68,17 +202,78 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
||||
Must be unique and follow DNS naming conventions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreateKey && (
|
||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<Checkbox
|
||||
checked={withKey}
|
||||
className="mt-0.5"
|
||||
onCheckedChange={(checked) => setWithKey(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-[13.5px] font-medium">Also create an access key</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Optional, an S3 key scoped to this bucket. You can also do this later from Access control.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{withKey && (
|
||||
<div className="space-y-4 border-t border-[var(--border)] pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-bucket-key-name" className="text-[13px] font-medium">
|
||||
Key name
|
||||
</label>
|
||||
<Input
|
||||
id="new-bucket-key-name"
|
||||
placeholder={derivedKeyName}
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
/>
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Leave empty to use {derivedKeyName}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[13px] font-medium">Permissions</label>
|
||||
<div className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
|
||||
{PERMISSION_ROWS.map((p) => (
|
||||
<label
|
||||
key={p.field}
|
||||
htmlFor={`new-bucket-key-${p.field}`}
|
||||
className="flex cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[var(--accent)]"
|
||||
>
|
||||
<Checkbox
|
||||
id={`new-bucket-key-${p.field}`}
|
||||
checked={permissions[p.field]}
|
||||
className="mt-0.5"
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((prev) => ({ ...prev, [p.field]: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-[13.5px] font-medium">{p.label}</div>
|
||||
<p className="mt-0.5 font-mono text-[12px] text-[var(--muted-foreground)]">
|
||||
{p.desc}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={!bucketName}
|
||||
>
|
||||
Create
|
||||
<Button variant="primary" onClick={handleCreate} disabled={!bucketName || creating}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,8 +5,9 @@ import {Input} from '@/components/ui/input';
|
||||
import {ObjectsTable} from './ObjectsTable';
|
||||
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
|
||||
import {DeleteObjectDialog} from './DeleteObjectDialog';
|
||||
import {ConfirmDialog} from '@/components/ui/confirm-dialog';
|
||||
import {UploadProgress} from './UploadProgress';
|
||||
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
|
||||
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, ScanSearch, Search, Trash, Upload} from 'lucide-react';
|
||||
import {getBreadcrumbs} from '@/lib/file-utils';
|
||||
import type {S3Object, UploadTask} from '@/types';
|
||||
|
||||
@@ -15,18 +16,21 @@ 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[], prefixes?: string[]) => Promise<boolean>;
|
||||
onCreateDirectory?: (name: string) => Promise<boolean>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onPageChange: (token?: string) => void;
|
||||
onItemsPerPageChange: (count: number) => void;
|
||||
@@ -41,11 +45,14 @@ export function ObjectBrowserView({
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
filterQuery,
|
||||
deepSearch,
|
||||
isLoading = false,
|
||||
isTruncated = false,
|
||||
nextContinuationToken,
|
||||
itemsPerPage,
|
||||
onSearchChange,
|
||||
onDeepSearchChange,
|
||||
onNavigateToFolder,
|
||||
onBackToBuckets,
|
||||
onUploadFiles,
|
||||
@@ -66,9 +73,15 @@ export function ObjectBrowserView({
|
||||
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
|
||||
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
|
||||
const [selectedFileKeys, setSelectedFileKeys] = useState<Set<string>>(new Set());
|
||||
const [selectedFolderKeys, setSelectedFolderKeys] = useState<Set<string>>(new Set());
|
||||
// Holds the keys/prefixes awaiting confirmation in the bulk-delete dialog.
|
||||
const [pendingDelete, setPendingDelete] = useState<{ keys: string[]; prefixes: string[] } | null>(null);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
|
||||
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[] = [];
|
||||
|
||||
@@ -95,6 +108,7 @@ export function ObjectBrowserView({
|
||||
setShowUploadZone(false);
|
||||
},
|
||||
noClick: true,
|
||||
disabled: !onUploadFiles,
|
||||
});
|
||||
|
||||
// Helper function to traverse file/directory tree
|
||||
@@ -124,36 +138,92 @@ export function ObjectBrowserView({
|
||||
});
|
||||
};
|
||||
|
||||
const selectedCount = selectedFileKeys.size + selectedFolderKeys.size;
|
||||
|
||||
const toggleInSet = (set: Set<string>, key: string) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const handleToggleFileSelection = (key: string) => {
|
||||
const newSelected = new Set(selectedFileKeys);
|
||||
if (newSelected.has(key)) {
|
||||
newSelected.delete(key);
|
||||
} else {
|
||||
newSelected.add(key);
|
||||
}
|
||||
setSelectedFileKeys(newSelected);
|
||||
setSelectedFileKeys(prev => toggleInSet(prev, key));
|
||||
};
|
||||
|
||||
const handleSelectAllFiles = () => {
|
||||
const fileKeys = objects
|
||||
.filter(obj => !obj.isFolder)
|
||||
.map(obj => obj.key);
|
||||
const handleToggleFolderSelection = (key: string) => {
|
||||
setSelectedFolderKeys(prev => toggleInSet(prev, key));
|
||||
};
|
||||
|
||||
if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) {
|
||||
setSelectedFileKeys(new Set());
|
||||
// Select/deselect the currently visible (filtered) rows. The table passes the
|
||||
// keys it is actually showing so this stays aligned with the search filter
|
||||
// instead of operating on the full, unfiltered object list.
|
||||
const handleSelectAll = (fileKeys: string[], folderKeys: string[]) => {
|
||||
const allVisibleSelected =
|
||||
fileKeys.length + folderKeys.length > 0 &&
|
||||
fileKeys.every(k => selectedFileKeys.has(k)) &&
|
||||
folderKeys.every(k => selectedFolderKeys.has(k));
|
||||
|
||||
if (allVisibleSelected) {
|
||||
// Drop only the visible rows, leaving any off-screen selection intact.
|
||||
setSelectedFileKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
fileKeys.forEach(k => next.delete(k));
|
||||
return next;
|
||||
});
|
||||
setSelectedFolderKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
folderKeys.forEach(k => next.delete(k));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedFileKeys(new Set(fileKeys));
|
||||
setSelectedFileKeys(prev => new Set([...prev, ...fileKeys]));
|
||||
setSelectedFolderKeys(prev => new Set([...prev, ...folderKeys]));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDeleteFiles = async () => {
|
||||
if (selectedFileKeys.size === 0) return;
|
||||
// Open the confirmation dialog for the current multi-selection.
|
||||
const handleRequestBulkDelete = () => {
|
||||
if (selectedCount === 0) return;
|
||||
setPendingDelete({
|
||||
keys: Array.from(selectedFileKeys),
|
||||
prefixes: Array.from(selectedFolderKeys),
|
||||
});
|
||||
};
|
||||
|
||||
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
|
||||
setSelectedFileKeys(new Set());
|
||||
// Open the confirmation dialog for a single folder (recursive delete).
|
||||
const handleDeleteFolder = (folderKey: string) => {
|
||||
setPendingDelete({ keys: [], prefixes: [folderKey] });
|
||||
};
|
||||
|
||||
const handleConfirmBulkDelete = async () => {
|
||||
if (!pendingDelete || !onDeleteMultipleObjects) return;
|
||||
|
||||
setBulkDeleting(true);
|
||||
const success = await onDeleteMultipleObjects(pendingDelete.keys, pendingDelete.prefixes);
|
||||
setBulkDeleting(false);
|
||||
|
||||
if (success) {
|
||||
// Drop the deleted folders/files from the live selection.
|
||||
setSelectedFileKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
pendingDelete.keys.forEach(k => next.delete(k));
|
||||
return next;
|
||||
});
|
||||
setSelectedFolderKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
pendingDelete.prefixes.forEach(k => next.delete(k));
|
||||
return next;
|
||||
});
|
||||
setPendingDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteObject = async (key: string): Promise<boolean> => {
|
||||
if (!onDeleteObject) return false;
|
||||
const success = await onDeleteObject(key);
|
||||
if (success) {
|
||||
setDeleteObjectDialogOpen(false);
|
||||
@@ -163,6 +233,7 @@ export function ObjectBrowserView({
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
if (!onUploadFiles) return;
|
||||
await onUploadFiles(files);
|
||||
setShowUploadZone(false);
|
||||
};
|
||||
@@ -199,34 +270,55 @@ export function ObjectBrowserView({
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<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 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>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedFileKeys.size > 0 && (
|
||||
{onDeleteMultipleObjects && selectedCount > 0 && (
|
||||
<Button
|
||||
onClick={handleBulkDeleteFiles}
|
||||
title={`Delete ${selectedFileKeys.size} selected file(s)`}
|
||||
onClick={handleRequestBulkDelete}
|
||||
title={`Delete ${selectedCount} selected item(s)`}
|
||||
className="bg-transparent border border-red-500 text-red-500 hover:bg-red-500/5"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
|
||||
Delete {selectedCount} item{selectedCount !== 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>
|
||||
@@ -234,7 +326,7 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
{showUploadZone && uploadTasks.length === 0 && (
|
||||
{onUploadFiles && 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">
|
||||
@@ -352,19 +444,24 @@ export function ObjectBrowserView({
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
filterQuery={filterQuery}
|
||||
deepSearch={deepSearch}
|
||||
selectedFileKeys={selectedFileKeys}
|
||||
selectedFolderKeys={selectedFolderKeys}
|
||||
isDragActive={isDragActive}
|
||||
isLoading={isLoading && !isRefreshing && !isNavigating}
|
||||
isTruncated={isTruncated}
|
||||
nextContinuationToken={nextContinuationToken}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onNavigateToFolder={onNavigateToFolder}
|
||||
onDeleteObject={(obj) => {
|
||||
onDeleteObject={onDeleteObject ? (obj) => {
|
||||
setSelectedObject(obj);
|
||||
setDeleteObjectDialogOpen(true);
|
||||
}}
|
||||
} : undefined}
|
||||
onDeleteFolder={onDeleteMultipleObjects ? (obj) => handleDeleteFolder(obj.key) : undefined}
|
||||
onToggleFileSelection={handleToggleFileSelection}
|
||||
onSelectAllFiles={handleSelectAllFiles}
|
||||
onToggleFolderSelection={handleToggleFolderSelection}
|
||||
onSelectAll={handleSelectAll}
|
||||
onPageChange={onPageChange}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
initialPageToken={initialPageToken}
|
||||
@@ -374,12 +471,14 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
|
||||
{/* Create Directory Dialog */}
|
||||
<CreateDirectoryDialog
|
||||
open={createDirDialogOpen}
|
||||
onOpenChange={setCreateDirDialogOpen}
|
||||
currentPath={currentPath}
|
||||
onCreateDirectory={onCreateDirectory}
|
||||
/>
|
||||
{onCreateDirectory && (
|
||||
<CreateDirectoryDialog
|
||||
open={createDirDialogOpen}
|
||||
onOpenChange={setCreateDirDialogOpen}
|
||||
currentPath={currentPath}
|
||||
onCreateDirectory={onCreateDirectory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Object Dialog */}
|
||||
<DeleteObjectDialog
|
||||
@@ -388,6 +487,53 @@ export function ObjectBrowserView({
|
||||
object={selectedObject}
|
||||
onDeleteObject={handleDeleteObject}
|
||||
/>
|
||||
|
||||
{/* Bulk / Folder Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !bulkDeleting) setPendingDelete(null);
|
||||
}}
|
||||
title={getBulkDeleteTitle(pendingDelete)}
|
||||
description={getBulkDeleteDescription(pendingDelete)}
|
||||
confirmLabel="Delete"
|
||||
loading={bulkDeleting}
|
||||
onConfirm={handleConfirmBulkDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Builds a concise title summarising what the bulk-delete dialog will remove.
|
||||
function getBulkDeleteTitle(pending: { keys: string[]; prefixes: string[] } | null): string {
|
||||
if (!pending) return 'Delete items?';
|
||||
const { keys, prefixes } = pending;
|
||||
const total = keys.length + prefixes.length;
|
||||
if (keys.length === 0 && prefixes.length === 1) {
|
||||
return 'Delete folder?';
|
||||
}
|
||||
return `Delete ${total} item${total !== 1 ? 's' : ''}?`;
|
||||
}
|
||||
|
||||
// Spells out the file/folder counts and warns that folders are removed recursively.
|
||||
function getBulkDeleteDescription(
|
||||
pending: { keys: string[]; prefixes: string[] } | null,
|
||||
): string {
|
||||
if (!pending) return '';
|
||||
const { keys, prefixes } = pending;
|
||||
const parts: string[] = [];
|
||||
if (keys.length > 0) {
|
||||
parts.push(`${keys.length} file${keys.length !== 1 ? 's' : ''}`);
|
||||
}
|
||||
if (prefixes.length > 0) {
|
||||
parts.push(`${prefixes.length} folder${prefixes.length !== 1 ? 's' : ''}`);
|
||||
}
|
||||
const summary = parts.join(' and ');
|
||||
|
||||
if (prefixes.length > 0) {
|
||||
return `This will permanently delete ${summary}. Every object stored inside the selected folder${
|
||||
prefixes.length !== 1 ? 's' : ''
|
||||
} will be removed recursively.`;
|
||||
}
|
||||
return `This will permanently delete ${summary}.`;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { ObjectPreview } from '@/components/buckets/ObjectPreview';
|
||||
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { downloadObject, formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
function CardSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
@@ -36,6 +39,12 @@ 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 canRead = canBucket(bucket, 'object.read');
|
||||
|
||||
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -73,22 +82,9 @@ export function ObjectDetailsView() {
|
||||
toast.success(label);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const handleDownload = () => {
|
||||
if (!bucketName || !objectKey) return;
|
||||
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
|
||||
}
|
||||
downloadObject(bucketName, objectKey);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -174,9 +170,11 @@ export function ObjectDetailsView() {
|
||||
<Button variant="secondary" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -220,9 +218,19 @@ export function ObjectDetailsView() {
|
||||
|
||||
{/* Preview */}
|
||||
<CardSection title="Preview">
|
||||
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
|
||||
No preview available for this object.
|
||||
</div>
|
||||
{canRead && bucketName && objectKey ? (
|
||||
<ObjectPreview
|
||||
bucket={bucketName}
|
||||
objectKey={objectKey}
|
||||
size={metadata.size}
|
||||
contentType={metadata.contentType}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
|
||||
No preview available for this object.
|
||||
</div>
|
||||
)}
|
||||
</CardSection>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ObjectPreviewState } from '@/hooks/useObjectPreview';
|
||||
import { useObjectPreview } from '@/hooks/useObjectPreview';
|
||||
import { TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
|
||||
import { ObjectPreview } from './ObjectPreview';
|
||||
|
||||
vi.mock('@/hooks/useObjectPreview', () => ({ useObjectPreview: vi.fn() }));
|
||||
|
||||
const mockedHook = vi.mocked(useObjectPreview);
|
||||
|
||||
function state(overrides: Partial<ObjectPreviewState>): ObjectPreviewState {
|
||||
return {
|
||||
kind: 'none',
|
||||
status: 'unsupported',
|
||||
objectUrl: null,
|
||||
text: null,
|
||||
mediaUrl: null,
|
||||
retry: vi.fn(),
|
||||
onMediaError: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
// A .json key keeps the highlight language deterministic in the text test.
|
||||
return render(
|
||||
<ObjectPreview bucket="b" objectKey="k.json" size={100} contentType="text/plain" onDownload={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ObjectPreview', () => {
|
||||
it('shows the loading state', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'loading' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/loading preview/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an image from the object url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'ready', objectUrl: 'blob:img' }));
|
||||
renderPreview();
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:img');
|
||||
});
|
||||
|
||||
it('renders video with the media url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const video = container.querySelector('video');
|
||||
expect(video).not.toBeNull();
|
||||
expect(video).toHaveAttribute('src', '/u?pt=t');
|
||||
expect(video).not.toHaveAttribute('autoplay');
|
||||
});
|
||||
|
||||
it('renders audio with the media url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('audio')).toHaveAttribute('src', '/u?pt=t');
|
||||
});
|
||||
|
||||
it('renders pdf in an iframe', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'pdf', status: 'ready', objectUrl: 'blob:pdf' }));
|
||||
renderPreview();
|
||||
expect(screen.getByTitle('k.json')).toHaveAttribute('src', 'blob:pdf');
|
||||
});
|
||||
|
||||
it('renders plain text immediately and highlighted text after the import resolves', async () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('pre')).toHaveTextContent('{"a": 1}');
|
||||
await waitFor(() => expect(container.querySelector('code [class*="hljs-"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('shows the too-large notice with a download action', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'too-large' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/too large to preview/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unsupported notice', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'none', status: 'unsupported' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the binary notice', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'binary' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/doesn't appear to be text/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the error state with retry wired to the hook', () => {
|
||||
const retry = vi.fn();
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'error', retry }));
|
||||
renderPreview();
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
expect(retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The tests below are additional to the brief's list. They were added to
|
||||
// close coverage gaps (media error and resume handling, the highlight size
|
||||
// guard, and the defensive fallback branch) found while verifying the
|
||||
// 90 percent coverage requirement on this file.
|
||||
|
||||
it('skips highlighting for text over the highlight size limit', () => {
|
||||
const bigText = 'a'.repeat(TEXT_HIGHLIGHT_MAX_BYTES + 1);
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: bigText }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('code [class*="hljs-"]')).toBeNull();
|
||||
expect(container.querySelector('code')?.textContent).toHaveLength(bigText.length);
|
||||
});
|
||||
|
||||
it('unmounting before the highlight import resolves is safe', async () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
|
||||
const { container, unmount } = renderPreview();
|
||||
expect(() => unmount()).not.toThrow();
|
||||
expect(container.innerHTML).toBe('');
|
||||
// Let the pending dynamic import resolve after unmount. The cancelled
|
||||
// guard means the resolved callback renders nothing back into the
|
||||
// detached container, so it stays empty and no error is thrown.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('resumes the playback position after a media error and reload', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const video = container.querySelector('video')!;
|
||||
|
||||
// Loading metadata with no prior error is a no-op (resume position is 0).
|
||||
fireEvent.loadedMetadata(video);
|
||||
|
||||
Object.defineProperty(video, 'currentTime', { value: 42, writable: true, configurable: true });
|
||||
fireEvent.error(video);
|
||||
Object.defineProperty(video, 'currentTime', { value: 0, writable: true, configurable: true });
|
||||
fireEvent.loadedMetadata(video);
|
||||
expect(video.currentTime).toBe(42);
|
||||
});
|
||||
|
||||
it('does not restore a position when the media element reported no currentTime', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const audio = container.querySelector('audio')!;
|
||||
|
||||
// The element reports no currentTime, so the captured resume position
|
||||
// falls back to 0 via the ?? 0 guard, which keeps the restore guard
|
||||
// (resumeAtRef.current > 0) false. A later loadedmetadata must then leave
|
||||
// the position untouched, unlike the sibling resume test above.
|
||||
Object.defineProperty(audio, 'currentTime', { value: undefined, writable: true, configurable: true });
|
||||
fireEvent.error(audio);
|
||||
Object.defineProperty(audio, 'currentTime', { value: 99, writable: true, configurable: true });
|
||||
fireEvent.loadedMetadata(audio);
|
||||
expect(audio.currentTime).toBe(99);
|
||||
});
|
||||
|
||||
it('falls back to the generic notice for an unhandled preview kind', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'none', status: 'ready' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useObjectPreview } from '@/hooks/useObjectPreview';
|
||||
import { getHighlightLanguage, TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
|
||||
function Notice({
|
||||
message,
|
||||
onDownload,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onDownload?: () => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
|
||||
<p>{message}</p>
|
||||
{(onRetry || onDownload) && (
|
||||
<div className="flex gap-2">
|
||||
{onRetry && (
|
||||
<Button variant="secondary" onClick={onRetry}>
|
||||
<RefreshCw className="h-4 w-4" /> Retry
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button variant="secondary" onClick={onDownload}>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ text, objectKey }: { text: string; objectKey: string }) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Highlighting is progressive enhancement. Large files and any import or
|
||||
// highlight failure fall back to the plain text already on screen.
|
||||
if (text.length > TEXT_HIGHLIGHT_MAX_BYTES) return;
|
||||
let cancelled = false;
|
||||
import('@/lib/highlight')
|
||||
.then(({ highlight }) => {
|
||||
if (!cancelled) setHtml(highlight(text, getHighlightLanguage(objectKey)));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [text, objectKey]);
|
||||
|
||||
return (
|
||||
<pre className="overflow-x-auto px-5 py-4 font-mono text-[12.5px] leading-relaxed">
|
||||
{html !== null ? <code dangerouslySetInnerHTML={{ __html: html }} /> : <code>{text}</code>}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObjectPreview({
|
||||
bucket,
|
||||
objectKey,
|
||||
size,
|
||||
contentType,
|
||||
onDownload,
|
||||
}: {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const preview = useObjectPreview(bucket, objectKey, size, contentType);
|
||||
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
const resumeAtRef = useRef(0);
|
||||
|
||||
const handleMediaError = () => {
|
||||
resumeAtRef.current = mediaRef.current?.currentTime ?? 0;
|
||||
preview.onMediaError();
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (resumeAtRef.current > 0 && mediaRef.current) {
|
||||
mediaRef.current.currentTime = resumeAtRef.current;
|
||||
resumeAtRef.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
switch (preview.status) {
|
||||
case 'unsupported':
|
||||
return <Notice message="No preview available for this object." onDownload={onDownload} />;
|
||||
case 'too-large':
|
||||
return (
|
||||
<Notice
|
||||
message={`File is too large to preview (${formatBytes(size)}), download it instead.`}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
case 'binary':
|
||||
return <Notice message="This file doesn't appear to be text." onDownload={onDownload} />;
|
||||
case 'error':
|
||||
return <Notice message="Could not load the preview." onRetry={preview.retry} onDownload={onDownload} />;
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 px-5 py-10 text-[13px] text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading preview…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (preview.kind) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex justify-center bg-[var(--surface-sunken)] px-5 py-6">
|
||||
<img src={preview.objectUrl!} alt={objectKey} className="h-auto max-w-full object-contain" />
|
||||
</div>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<div className="flex justify-center bg-black">
|
||||
<video
|
||||
ref={(el) => {
|
||||
mediaRef.current = el;
|
||||
}}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={preview.mediaUrl!}
|
||||
onError={handleMediaError}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
className="max-h-[85vh] w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="px-5 py-6">
|
||||
<audio
|
||||
ref={(el) => {
|
||||
mediaRef.current = el;
|
||||
}}
|
||||
controls
|
||||
src={preview.mediaUrl!}
|
||||
onError={handleMediaError}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'pdf':
|
||||
return <iframe src={preview.objectUrl!} title={objectKey} className="h-[85vh] w-full" />;
|
||||
case 'text':
|
||||
return <CodeBlock text={preview.text!} objectKey={objectKey} />;
|
||||
default:
|
||||
return <Notice message="No preview available for this object." onDownload={onDownload} />;
|
||||
}
|
||||
}
|
||||
@@ -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 {formatBytes, formatRelativeTime} from '@/lib/file-utils';
|
||||
import {downloadObject, formatBytes, formatRelativeTime} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
|
||||
interface ObjectsTableProps {
|
||||
@@ -22,16 +22,25 @@ interface ObjectsTableProps {
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
filterQuery: string;
|
||||
deepSearch: boolean;
|
||||
selectedFileKeys: Set<string>;
|
||||
selectedFolderKeys: Set<string>;
|
||||
isDragActive: boolean;
|
||||
isLoading?: boolean;
|
||||
isTruncated?: boolean;
|
||||
nextContinuationToken?: string;
|
||||
itemsPerPage: number;
|
||||
onNavigateToFolder: (key: string) => void;
|
||||
onDeleteObject: (object: S3Object) => void;
|
||||
// Optional so the parent can withhold them when the user lacks delete
|
||||
// permission; canDelete (below) is derived from onDeleteObject.
|
||||
onDeleteObject?: (object: S3Object) => void;
|
||||
onDeleteFolder?: (object: S3Object) => void;
|
||||
onToggleFileSelection: (key: string) => void;
|
||||
onSelectAllFiles: () => void;
|
||||
onToggleFolderSelection: (key: string) => void;
|
||||
// Receives the keys of the currently *visible* (filtered) rows so selection
|
||||
// stays aligned with what the search is actually showing.
|
||||
onSelectAll: (fileKeys: string[], folderKeys: string[]) => void;
|
||||
onPageChange: (token?: string) => void;
|
||||
onItemsPerPageChange: (count: number) => void;
|
||||
initialPageToken?: string;
|
||||
@@ -46,7 +55,10 @@ export function ObjectsTable({
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
filterQuery,
|
||||
deepSearch,
|
||||
selectedFileKeys,
|
||||
selectedFolderKeys,
|
||||
isDragActive,
|
||||
isLoading = false,
|
||||
isTruncated = false,
|
||||
@@ -54,14 +66,17 @@ export function ObjectsTable({
|
||||
itemsPerPage,
|
||||
onNavigateToFolder,
|
||||
onDeleteObject,
|
||||
onDeleteFolder,
|
||||
onToggleFileSelection,
|
||||
onSelectAllFiles,
|
||||
onToggleFolderSelection,
|
||||
onSelectAll,
|
||||
onPageChange,
|
||||
onItemsPerPageChange,
|
||||
initialPageToken,
|
||||
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), ...]
|
||||
@@ -86,7 +101,9 @@ export function ObjectsTable({
|
||||
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
||||
|
||||
const filteredObjects = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
// 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 filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query));
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aIsFolder = a.isFolder ? 1 : 0;
|
||||
@@ -96,8 +113,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;
|
||||
}
|
||||
@@ -114,13 +131,15 @@ export function ObjectsTable({
|
||||
|
||||
return sortDirection === 'asc' ? compareValue : -compareValue;
|
||||
});
|
||||
}, [objects, searchQuery, sortColumn, sortDirection, currentPath]);
|
||||
}, [objects, filterQuery, sortColumn, sortDirection, currentPath]);
|
||||
|
||||
// Effect 2: Reset pagination ONLY on path navigation
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
setPageTokens([undefined]);
|
||||
setCurrentPageIndex(0);
|
||||
}, [currentPath]);
|
||||
}, [currentPath, searchQuery, deepSearch]);
|
||||
|
||||
// Update page tokens when we get a new next token
|
||||
useEffect(() => {
|
||||
@@ -137,11 +156,33 @@ export function ObjectsTable({
|
||||
}
|
||||
}, [nextContinuationToken, isTruncated, currentPageIndex]);
|
||||
|
||||
const hasPrevious = currentPageIndex > 0;
|
||||
const hasNext = isTruncated;
|
||||
// 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 handleNextPage = () => {
|
||||
if (hasNext && nextContinuationToken) {
|
||||
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) {
|
||||
const nextIndex = currentPageIndex + 1;
|
||||
setCurrentPageIndex(nextIndex);
|
||||
onPageChange(nextContinuationToken);
|
||||
@@ -150,13 +191,16 @@ export function ObjectsTable({
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (hasPrevious) {
|
||||
const prevIndex = currentPageIndex - 1;
|
||||
setCurrentPageIndex(prevIndex);
|
||||
const previousToken = pageTokens[prevIndex];
|
||||
onPageChange(previousToken);
|
||||
if (!hasPrevious) return;
|
||||
if (clientPaginated) {
|
||||
setCurrentPageIndex(pageIndex - 1);
|
||||
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) => {
|
||||
@@ -181,16 +225,29 @@ export function ObjectsTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<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>
|
||||
{canDelete && (
|
||||
<TableHead className="w-[50px]">
|
||||
<Checkbox
|
||||
// Scope select-all to the rows actually on screen (pageObjects).
|
||||
// In normal/prefix browsing this equals filteredObjects; in
|
||||
// client-paginated deep search it is just the visible page, so
|
||||
// one click never selects hidden matches for a destructive delete.
|
||||
checked={
|
||||
pageObjects.length > 0 &&
|
||||
pageObjects.every(obj =>
|
||||
obj.isFolder ? selectedFolderKeys.has(obj.key) : selectedFileKeys.has(obj.key),
|
||||
)
|
||||
}
|
||||
onCheckedChange={() =>
|
||||
onSelectAll(
|
||||
pageObjects.filter(obj => !obj.isFolder).map(obj => obj.key),
|
||||
pageObjects.filter(obj => obj.isFolder).map(obj => obj.key),
|
||||
)
|
||||
}
|
||||
aria-label="Select all objects"
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('name')}
|
||||
@@ -217,7 +274,7 @@ export function ObjectsTable({
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<TableCell colSpan={canDelete ? 7 : 6} 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>
|
||||
@@ -226,7 +283,7 @@ export function ObjectsTable({
|
||||
</TableRow>
|
||||
) : filteredObjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery
|
||||
? 'No objects found matching your search'
|
||||
: isDragActive
|
||||
@@ -235,24 +292,25 @@ export function ObjectsTable({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredObjects.map((obj) => (
|
||||
pageObjects.map((obj) => (
|
||||
<TableRow key={obj.key}>
|
||||
<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>
|
||||
{canDelete && (
|
||||
<TableCell className="w-[50px]">
|
||||
{obj.isFolder ? (
|
||||
<Checkbox
|
||||
checked={selectedFolderKeys.has(obj.key)}
|
||||
onCheckedChange={() => onToggleFolderSelection(obj.key)}
|
||||
aria-label={`Select folder ${obj.key} (deletes its contents recursively)`}
|
||||
/>
|
||||
) : (
|
||||
<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 ? (
|
||||
@@ -265,7 +323,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
|
||||
@@ -341,7 +399,33 @@ export function ObjectsTable({
|
||||
})() : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!obj.isFolder && (
|
||||
{obj.isFolder ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onNavigateToFolder(obj.key)}>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
{onDeleteFolder && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteFolder(obj)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete folder
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
|
||||
@@ -353,18 +437,22 @@ export function ObjectsTable({
|
||||
<Eye className="h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => downloadObject(bucketName, obj.key)}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteObject(obj)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{onDeleteObject && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteObject(obj)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -395,7 +483,9 @@ export function ObjectsTable({
|
||||
{/* Pagination info and controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPageIndex + 1} • Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''}
|
||||
{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' : ''}`}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -5,19 +5,21 @@ 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' },
|
||||
{ to: 'permissions', label: 'Permissions' },
|
||||
{ to: 'website', label: 'Website' },
|
||||
{ to: 'settings', label: 'Settings' },
|
||||
{ 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'] },
|
||||
];
|
||||
|
||||
function formatBytes(n?: number) {
|
||||
@@ -36,6 +38,8 @@ 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 () => {
|
||||
@@ -68,16 +72,18 @@ export function BucketDetailShell() {
|
||||
<Button variant="secondary" onClick={copyUrl}>
|
||||
<Copy /> Copy URL
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
|
||||
<Upload /> Upload
|
||||
</Button>
|
||||
{canBucket(bucket, 'object.write') && (
|
||||
<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">
|
||||
{tabs.map((t) => (
|
||||
{visibleTabs.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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();
|
||||
@@ -35,6 +37,7 @@ 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)]">
|
||||
@@ -59,7 +62,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">
|
||||
<Outlet />
|
||||
{noAccess ? <NoAccess /> : <Outlet />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,13 @@ 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 {
|
||||
@@ -22,13 +24,15 @@ const navGroups: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
items: [{ title: 'Buckets', href: '/buckets', icon: Database }],
|
||||
items: [
|
||||
{ title: 'Buckets', href: '/buckets', icon: Database, visible: (p) => p.hasAnyPerm('bucket.list') },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cluster',
|
||||
items: [
|
||||
{ title: 'Cluster', href: '/cluster', icon: Server },
|
||||
{ title: 'Access Control', href: '/access', icon: Key },
|
||||
{ title: 'Cluster', href: '/cluster', icon: Server, visible: (p) => p.hasAnyClusterAccess },
|
||||
{ title: 'Access Control', href: '/access', icon: Key, visible: (p) => p.hasClusterPerm('key.list') },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -41,6 +45,7 @@ interface SidebarProps {
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const { config } = useAuthStore();
|
||||
const perms = usePermissions();
|
||||
|
||||
const { data: uiVersion } = useQuery({
|
||||
queryKey: ['ui-version'],
|
||||
@@ -77,38 +82,42 @@ 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) => (
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function CredentialField({
|
||||
label,
|
||||
value,
|
||||
mono = true,
|
||||
breakAll = false,
|
||||
maskable = false,
|
||||
loading = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
breakAll?: boolean;
|
||||
maskable?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [revealed, setRevealed] = useState(!maskable);
|
||||
const copy = () => {
|
||||
if (!value) return;
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
toast.success(`${label} copied`);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40));
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
disabled={loading || !value}
|
||||
title="Click to copy"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
|
||||
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
|
||||
mono && 'font-mono',
|
||||
breakAll ? 'break-all' : 'truncate',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
display
|
||||
)}
|
||||
</button>
|
||||
{maskable && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
aria-label={revealed ? 'Hide' : 'Reveal'}
|
||||
disabled={loading || !value}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" onClick={copy} aria-label={`Copy ${label}`} disabled={loading || !value}>
|
||||
{copied ? <Check className="h-4 w-4 text-[var(--primary)]" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {placeUnder} from '@/lib/popup-position';
|
||||
|
||||
interface DropdownMenuContextValue {
|
||||
open: boolean;
|
||||
@@ -62,36 +63,30 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
|
||||
({ className, children, align = 'start', ...props }) => {
|
||||
const { open, setOpen, triggerRef } = useDropdownMenu();
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
||||
const [position, setPosition] = React.useState<React.CSSProperties>({});
|
||||
|
||||
// Positioned in viewport coordinates against the trigger, since the menu is
|
||||
// portalled to the body and rendered fixed.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Calculate position based on trigger element
|
||||
React.useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
if (open && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
|
||||
let left = rect.left + scrollX;
|
||||
const top = rect.bottom + scrollY + 8; // 8px gap (mt-2)
|
||||
|
||||
// Adjust horizontal alignment
|
||||
if (align === 'end') {
|
||||
left = rect.right + scrollX - 224; // 224px = w-56
|
||||
} else if (align === 'center') {
|
||||
left = rect.left + scrollX + (rect.width / 2) - 112; // 112px = half of w-56
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
let left = rect.left;
|
||||
if (align === 'end') {
|
||||
left = rect.right - 224; // 224px = w-56
|
||||
} else if (align === 'center') {
|
||||
left = rect.left + rect.width / 2 - 112; // 112px = half of w-56
|
||||
}
|
||||
|
||||
setPosition({ left, ...placeUnder(rect, window.innerHeight, 8) });
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
if (open) {
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
}
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
@@ -126,11 +121,10 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
|
||||
style={{
|
||||
backgroundColor: 'var(--popover)',
|
||||
position: 'fixed',
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
...position,
|
||||
}}
|
||||
className={cn(
|
||||
'z-50 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
|
||||
'z-50 w-56 origin-top-right overflow-auto rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -151,7 +145,7 @@ const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<H
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {placeUnder} from '@/lib/popup-position';
|
||||
import {ChevronDown, Check} from 'lucide-react';
|
||||
|
||||
export interface SelectOption {
|
||||
@@ -32,11 +34,16 @@ const useSelectContext = () => {
|
||||
};
|
||||
|
||||
const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, _ref) => {
|
||||
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, ref) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalValue, setInternalValue] = React.useState(value);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const popupRef = React.useRef<HTMLDivElement>(null);
|
||||
const [popupStyle, setPopupStyle] = React.useState<React.CSSProperties>({});
|
||||
|
||||
// The forwarded ref points at the trigger, as it does on DropdownMenuTrigger.
|
||||
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
|
||||
|
||||
const displayValue = React.useMemo(() => {
|
||||
const currentValue = value ?? internalValue;
|
||||
@@ -62,11 +69,40 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
// The popup is portalled to the body so an ancestor with overflow-hidden
|
||||
// (a dialog card, a scroll container) cannot clip it.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const trigger = buttonRef.current;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
|
||||
setPopupStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
backgroundColor: 'var(--popover)',
|
||||
...placeUnder(rect, window.innerHeight),
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
const target = event.target as Node;
|
||||
if (containerRef.current?.contains(target) || popupRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (open) {
|
||||
@@ -106,13 +142,15 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
<ChevronDown className={cn('h-4 w-4 opacity-50 transition-transform', open && 'transform rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
{open && createPortal(
|
||||
<div
|
||||
className="absolute z-50 w-full mt-1 text-popover-foreground rounded-md border border-border shadow-lg max-h-60 overflow-auto"
|
||||
style={{ backgroundColor: 'var(--popover)' }}
|
||||
ref={popupRef}
|
||||
className="z-50 text-popover-foreground rounded-md border border-border shadow-lg overflow-auto"
|
||||
style={popupStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</SelectContext.Provider>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useDashboardData, useBuckets } from './useApi';
|
||||
export { useBucketObjects } from './useBucketObjects';
|
||||
export { usePermissions, bucketCan, useBucketCan } from './usePermissions';
|
||||
|
||||
@@ -142,8 +142,8 @@ export function useDeleteMultipleObjects() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, keys, prefix }: { bucket: string; keys: string[]; prefix?: string }) =>
|
||||
objectsApi.deleteMultiple(bucket, keys, prefix),
|
||||
mutationFn: ({ bucket, keys, prefixes }: { bucket: string; keys: string[]; prefixes?: string[] }) =>
|
||||
objectsApi.deleteMultiple(bucket, keys, prefixes),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
|
||||
@@ -3,7 +3,11 @@ import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object, UploadTask } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
|
||||
// How long to wait after the last keystroke before actually searching. Keeps
|
||||
// typing from firing a request (and a client-side re-filter) on every key.
|
||||
const SEARCH_DEBOUNCE_MS = 750;
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '', searchQuery: string = '', deepSearch: boolean = false) {
|
||||
const [objects, setObjects] = useState<S3Object[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -13,13 +17,25 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const previousPathRef = useRef<string>(currentPath);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
const clearTasksTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Monotonic sequence guarding against stale responses: when a newer fetch or
|
||||
// search starts, older in-flight responses are discarded instead of clobbering
|
||||
// the current view (e.g. a slow search resolving after the query was cleared).
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
// Prefix search (the default) narrows the Garage listing to keys starting with
|
||||
// the query, within the current folder — server-side, paginated, and O(matches)
|
||||
// like the AWS S3 / R2 consoles. Deep search instead uses a recursive scan
|
||||
// (see searchObjects) and does not touch listPrefix.
|
||||
const listPrefix = debouncedSearch && !deepSearch ? currentPath + debouncedSearch : currentPath;
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
|
||||
const seq = ++fetchSeqRef.current;
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setIsRefreshing(true);
|
||||
@@ -29,30 +45,70 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
const response = await objectsApi.list(bucketName, currentPath, itemsPerPage, continuationToken);
|
||||
const response = await objectsApi.list(bucketName, listPrefix, itemsPerPage, continuationToken);
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setObjects(response.objects);
|
||||
setIsTruncated(response.isTruncated);
|
||||
setNextContinuationToken(response.nextContinuationToken);
|
||||
setCurrentContinuationToken(continuationToken);
|
||||
} catch (err) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch objects:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setIsNavigating(false);
|
||||
if (seq === fetchSeqRef.current) {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setIsNavigating(false);
|
||||
}
|
||||
}
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
}, [bucketName, listPrefix, itemsPerPage]);
|
||||
|
||||
const searchObjects = useCallback(async (query: string) => {
|
||||
if (!bucketName) return;
|
||||
|
||||
const seq = ++fetchSeqRef.current;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await objectsApi.search(bucketName, query, currentPath || undefined);
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setObjects(response.objects);
|
||||
setIsTruncated(response.isTruncated);
|
||||
// Search results are not token-paginated.
|
||||
setNextContinuationToken(undefined);
|
||||
setCurrentContinuationToken(undefined);
|
||||
} catch (err) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setError(err as Error);
|
||||
console.error('Failed to search objects:', err);
|
||||
} finally {
|
||||
if (seq === fetchSeqRef.current) setIsLoading(false);
|
||||
}
|
||||
}, [bucketName, currentPath]);
|
||||
|
||||
// Debounce the search query so we don't fire a recursive scan per keystroke.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchQuery.trim()), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Deep search: recursive substring scan across the current subtree.
|
||||
if (debouncedSearch && deepSearch) {
|
||||
searchObjects(debouncedSearch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal listing, or prefix-filtered listing (listPrefix carries the query).
|
||||
const isPathChange = previousPathRef.current !== currentPath && objects.length > 0;
|
||||
previousPathRef.current = currentPath;
|
||||
|
||||
fetchObjects(undefined, false, isPathChange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
}, [bucketName, currentPath, itemsPerPage, debouncedSearch, deepSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -160,14 +216,24 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
}
|
||||
}, [bucketName, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const deleteMultipleObjects = useCallback(async (keys: string[]) => {
|
||||
if (!bucketName || keys.length === 0) return false;
|
||||
// Deletes the selected object keys and recursively deletes every object under
|
||||
// each selected folder prefix.
|
||||
const deleteMultipleObjects = useCallback(async (keys: string[], prefixes: string[] = []) => {
|
||||
if (!bucketName || (keys.length === 0 && prefixes.length === 0)) return false;
|
||||
|
||||
try {
|
||||
setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
|
||||
const keySet = new Set(keys);
|
||||
setObjects(prev => prev.filter(obj =>
|
||||
!keySet.has(obj.key) && !prefixes.some(prefix => obj.key.startsWith(prefix))
|
||||
));
|
||||
|
||||
await objectsApi.deleteMultiple(bucketName, keys, prefixes);
|
||||
|
||||
const fileLabel = keys.length > 0 ? `${keys.length} file${keys.length > 1 ? 's' : ''}` : '';
|
||||
const folderLabel = prefixes.length > 0 ? `${prefixes.length} folder${prefixes.length > 1 ? 's' : ''}` : '';
|
||||
const summary = [fileLabel, folderLabel].filter(Boolean).join(' and ');
|
||||
toast.success(`Successfully deleted ${summary}`);
|
||||
|
||||
await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
|
||||
toast.success(`Successfully deleted ${keys.length} file${keys.length > 1 ? 's' : ''}`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -175,7 +241,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
}, [bucketName, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const createDirectory = useCallback(async (dirName: string) => {
|
||||
if (!bucketName) return false;
|
||||
@@ -194,6 +260,9 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
|
||||
return {
|
||||
objects,
|
||||
// The debounced query the current results reflect — use this (not the raw
|
||||
// input) to filter/label results so the view waits instead of twitching.
|
||||
debouncedSearch,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import { useObjectPreview } from './useObjectPreview';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
objectsApi: {
|
||||
get: vi.fn(),
|
||||
getPreviewUrl: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedGet = vi.mocked(objectsApi.get);
|
||||
const mockedGetPreviewUrl = vi.mocked(objectsApi.getPreviewUrl);
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// One client per test, created outside the component so rerenders reuse it.
|
||||
function createWrapper() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('useObjectPreview', () => {
|
||||
it('fetches a blob and produces an object url for images', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])]));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.kind).toBe('image');
|
||||
expect(result.current.objectUrl).toBe('blob:mock-url');
|
||||
expect(mockedGet).toHaveBeenCalledWith('b', 'pic.png');
|
||||
});
|
||||
|
||||
it('decodes text content', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob(['{"a": 1}']));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'data.json', 8, 'application/json'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.text).toBe('{"a": 1}');
|
||||
});
|
||||
|
||||
it('reports binary content pretending to be text', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([0, 1, 2, 3, 4, 0, 1, 2, 3, 4])]));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'weird.log', 10, undefined), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('binary'));
|
||||
});
|
||||
|
||||
it('does not fetch when the object is over the limit', () => {
|
||||
const { result } = renderHook(
|
||||
() => useObjectPreview('b', 'big.txt', 6 * 1024 * 1024, 'text/plain'),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
expect(result.current.status).toBe('too-large');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports unsupported kinds without fetching', () => {
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'blob.bin', 10, undefined), { wrapper: createWrapper() });
|
||||
expect(result.current.status).toBe('unsupported');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints a media url for video without fetching bytes', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/api/v1/buckets/b/objects/v.mp4?pt=tok', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10_000_000_000, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.mediaUrl).toBe('/api/v1/buckets/b/objects/v.mp4?pt=tok');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-mints once on media error, then reports error', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
|
||||
expect(result.current.status).toBe('ready');
|
||||
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
});
|
||||
|
||||
it('reports fetch failures and recovers on retry', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('network down'));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
});
|
||||
|
||||
it('revokes the object url on unmount', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
|
||||
const { result, unmount } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
unmount();
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||
});
|
||||
|
||||
it('recovers when the target object changes after a media error', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/a?pt=1', expiresAt: 'later' });
|
||||
const { result, rerender } = renderHook(
|
||||
({ k }) => useObjectPreview('b', k, 10, 'video/mp4'),
|
||||
{ wrapper: createWrapper(), initialProps: { k: 'a.mp4' } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
// Exhaust the single re-mint on object A, driving it into the error state.
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
// Switch to object B on the same hook instance. The stale error from A
|
||||
// must not leave B stuck: it should load and reach ready.
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/b?pt=1', expiresAt: 'later' });
|
||||
rerender({ k: 'b.mp4' });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.mediaUrl).toBe('/b?pt=1');
|
||||
});
|
||||
|
||||
it('refetches the media url on retry', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('ignores a pending text decode after unmount', async () => {
|
||||
let resolveText: (value: string) => void = () => {};
|
||||
const blob = new Blob(['hello']);
|
||||
vi.spyOn(blob, 'text').mockReturnValue(new Promise<string>((res) => { resolveText = res; }));
|
||||
mockedGet.mockResolvedValue(blob);
|
||||
const { result, unmount } = renderHook(() => useObjectPreview('b', 'a.txt', 5, 'text/plain'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.objectUrl).toBe('blob:mock-url'));
|
||||
|
||||
// Unmount before the decode resolves, then resolve it. The cancelled
|
||||
// guard must swallow the late result rather than set state.
|
||||
unmount();
|
||||
act(() => resolveText('hello'));
|
||||
expect(result.current.text).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import {
|
||||
getPreviewKind,
|
||||
getPreviewMime,
|
||||
getPreviewSizeLimit,
|
||||
looksBinary,
|
||||
type PreviewKind,
|
||||
} from '@/lib/preview-utils';
|
||||
|
||||
export interface ObjectPreviewState {
|
||||
kind: PreviewKind;
|
||||
status: 'loading' | 'ready' | 'too-large' | 'unsupported' | 'binary' | 'error';
|
||||
objectUrl: string | null;
|
||||
text: string | null;
|
||||
mediaUrl: string | null;
|
||||
retry: () => void;
|
||||
onMediaError: () => void;
|
||||
}
|
||||
|
||||
// Documents (image, pdf, text) are fetched as blobs through the normal
|
||||
// authenticated API path. Media (video, audio) gets a short-lived tokenized
|
||||
// URL instead, because media elements cannot send an Authorization header
|
||||
// and must stream with Range requests rather than load fully.
|
||||
export function useObjectPreview(
|
||||
bucket: string,
|
||||
objectKey: string,
|
||||
size: number,
|
||||
contentType?: string,
|
||||
): ObjectPreviewState {
|
||||
const kind = getPreviewKind(contentType, objectKey);
|
||||
const sizeLimit = getPreviewSizeLimit(kind);
|
||||
const isDocument = kind === 'image' || kind === 'pdf' || kind === 'text';
|
||||
const isMedia = kind === 'video' || kind === 'audio';
|
||||
const tooLarge = isDocument && sizeLimit !== null && size > sizeLimit;
|
||||
|
||||
const blobQuery = useQuery({
|
||||
queryKey: ['object-preview', bucket, objectKey],
|
||||
queryFn: () => objectsApi.get(bucket, objectKey),
|
||||
enabled: isDocument && !tooLarge,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const urlQuery = useQuery({
|
||||
queryKey: ['object-preview-url', bucket, objectKey],
|
||||
queryFn: () => objectsApi.getPreviewUrl(bucket, objectKey),
|
||||
enabled: isMedia,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const [mediaFailed, setMediaFailed] = useState(false);
|
||||
const remintedRef = useRef(false);
|
||||
|
||||
// Reset media error state when the target object changes, so a failure on
|
||||
// one object does not leave a later object stuck in the error state. The
|
||||
// hook instance is reused across navigation, it does not remount.
|
||||
useEffect(() => {
|
||||
setMediaFailed(false);
|
||||
remintedRef.current = false;
|
||||
}, [bucket, objectKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const blob = blobQuery.data;
|
||||
if (!blob) return;
|
||||
let cancelled = false;
|
||||
const typed = new Blob([blob], { type: getPreviewMime(kind, contentType, objectKey) });
|
||||
const url = URL.createObjectURL(typed);
|
||||
setObjectUrl(url);
|
||||
if (kind === 'text') {
|
||||
blob.text().then((decoded) => {
|
||||
if (cancelled) return;
|
||||
if (looksBinary(decoded.slice(0, 4096))) setIsBinary(true);
|
||||
else setText(decoded);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
setObjectUrl(null);
|
||||
setText(null);
|
||||
setIsBinary(false);
|
||||
};
|
||||
}, [blobQuery.data, kind, contentType, objectKey]);
|
||||
|
||||
const onMediaError = () => {
|
||||
if (remintedRef.current) {
|
||||
setMediaFailed(true);
|
||||
return;
|
||||
}
|
||||
remintedRef.current = true;
|
||||
urlQuery.refetch();
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
remintedRef.current = false;
|
||||
setMediaFailed(false);
|
||||
if (isDocument) blobQuery.refetch();
|
||||
if (isMedia) urlQuery.refetch();
|
||||
};
|
||||
|
||||
let status: ObjectPreviewState['status'];
|
||||
if (kind === 'none') status = 'unsupported';
|
||||
else if (tooLarge) status = 'too-large';
|
||||
else if (isBinary) status = 'binary';
|
||||
else if (mediaFailed || blobQuery.isError || urlQuery.isError) status = 'error';
|
||||
else if (isMedia) status = urlQuery.data ? 'ready' : 'loading';
|
||||
else if (kind === 'text') status = text !== null ? 'ready' : 'loading';
|
||||
else status = objectUrl ? 'ready' : 'loading';
|
||||
|
||||
return {
|
||||
kind,
|
||||
status,
|
||||
objectUrl,
|
||||
text,
|
||||
mediaUrl: urlQuery.data?.url ?? null,
|
||||
retry,
|
||||
onMediaError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCapabilities } from './useCapabilities';
|
||||
|
||||
/**
|
||||
* Permission view derived from /api/v1/capabilities.
|
||||
*
|
||||
* Fail-closed: while capabilities are loading (or errored), every check
|
||||
* returns false. Gated UI stays hidden until the server has spoken.
|
||||
* When access control is disabled server-side, every check returns true.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { data, isLoading, isError } = useCapabilities();
|
||||
const ac = data?.access_control;
|
||||
const settled = !isLoading && !isError && data !== undefined;
|
||||
const enabled = ac?.enabled ?? false;
|
||||
const isAdmin = settled && (!enabled || (ac?.is_admin ?? false));
|
||||
const bindings = ac?.bindings ?? [];
|
||||
const clusterPerms = ac?.cluster_permissions ?? [];
|
||||
|
||||
const hasClusterPerm = (perm: string): boolean => {
|
||||
if (!settled) return false;
|
||||
if (!enabled || isAdmin) return true;
|
||||
return clusterPerms.includes(perm);
|
||||
};
|
||||
|
||||
const hasAnyPerm = (perm: string): boolean => {
|
||||
if (!settled) return false;
|
||||
if (!enabled || isAdmin) return true;
|
||||
return bindings.some((b) => b.permissions.includes(perm));
|
||||
};
|
||||
|
||||
const hasAnyClusterAccess =
|
||||
settled &&
|
||||
(!enabled || isAdmin || clusterPerms.some((p) => p.startsWith('cluster.') || p.startsWith('node.')));
|
||||
|
||||
const noAccess =
|
||||
settled && enabled && !isAdmin && bindings.length === 0 && clusterPerms.length === 0;
|
||||
|
||||
return { loading: !settled, enabled, isAdmin, hasClusterPerm, hasAnyPerm, hasAnyClusterAccess, noAccess };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket check against the server-computed effective_permissions carried
|
||||
* on bucket payloads. Does not know about loading/enabled state, so it treats
|
||||
* a missing effective_permissions field as "access control is disabled" and
|
||||
* allows. That's only correct when access control really is disabled, so
|
||||
* this is kept for internal use (by useBucketCan below); UI code should call
|
||||
* useBucketCan() instead, which fails closed when access control is enabled.
|
||||
*/
|
||||
export function bucketCan(
|
||||
bucket: { effective_permissions?: string[] } | undefined,
|
||||
perm: string,
|
||||
): boolean {
|
||||
const perms = bucket?.effective_permissions;
|
||||
if (!perms) return true;
|
||||
return perms.includes(perm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook returning a per-bucket permission check closed over the current
|
||||
* loading/enabled state from usePermissions().
|
||||
*
|
||||
* Fail-closed: while capabilities are loading, every check denies. When
|
||||
* access control is enabled, a bucket without effective_permissions (not
|
||||
* loaded yet, or filtered out of the bucket list) also denies instead of
|
||||
* falling open like bucketCan does. When access control is disabled, every
|
||||
* check allows, same as bucketCan.
|
||||
*/
|
||||
export function useBucketCan() {
|
||||
const { loading, enabled } = usePermissions();
|
||||
return (bucket: { effective_permissions?: string[] } | undefined, perm: string): boolean => {
|
||||
if (loading) return false;
|
||||
if (!enabled) return true;
|
||||
if (!bucket?.effective_permissions) return false;
|
||||
return bucketCan(bucket, perm);
|
||||
};
|
||||
}
|
||||
@@ -161,3 +161,77 @@
|
||||
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Syntax highlighting tokens for the object preview. The dark overrides ride
|
||||
the same root class the theme provider toggles. */
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6a737d;
|
||||
}
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #d73a49;
|
||||
}
|
||||
.hljs-number,
|
||||
.hljs-literal,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-string,
|
||||
.hljs-doctag,
|
||||
.hljs-regexp {
|
||||
color: #032f62;
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-name {
|
||||
color: #6f42c1;
|
||||
}
|
||||
.hljs-attr,
|
||||
.hljs-attribute,
|
||||
.hljs-built_in {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #e36209;
|
||||
}
|
||||
|
||||
.dark .hljs-comment,
|
||||
.dark .hljs-quote {
|
||||
color: #8b949e;
|
||||
}
|
||||
.dark .hljs-keyword,
|
||||
.dark .hljs-selector-tag,
|
||||
.dark .hljs-subst {
|
||||
color: #ff7b72;
|
||||
}
|
||||
.dark .hljs-number,
|
||||
.dark .hljs-literal,
|
||||
.dark .hljs-variable,
|
||||
.dark .hljs-template-variable {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .hljs-string,
|
||||
.dark .hljs-doctag,
|
||||
.dark .hljs-regexp {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
.dark .hljs-title,
|
||||
.dark .hljs-section,
|
||||
.dark .hljs-name {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
.dark .hljs-attr,
|
||||
.dark .hljs-attribute,
|
||||
.dark .hljs-built_in {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .hljs-meta,
|
||||
.dark .hljs-selector-id,
|
||||
.dark .hljs-selector-class {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
+43
-2
@@ -293,6 +293,39 @@ export const objectsApi = {
|
||||
};
|
||||
},
|
||||
|
||||
// Recursive, best-effort substring search across all objects under `prefix`.
|
||||
// The backend scans and filters (S3/Garage has no server-side substring
|
||||
// search), so this finds matches regardless of which page they'd be on.
|
||||
search: async (bucket: string, query: string, prefix?: string): Promise<ObjectListResponse> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params: any = { search: query };
|
||||
if (prefix) params.prefix = prefix;
|
||||
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects`, { params });
|
||||
const data = response.data.data;
|
||||
|
||||
// Search returns a flat list of matching objects (no folders/prefixes).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const objects: S3Object[] = data.objects?.map((obj: any) => ({
|
||||
key: obj.key,
|
||||
size: obj.size,
|
||||
lastModified: obj.last_modified,
|
||||
etag: obj.etag,
|
||||
contentType: obj.content_type,
|
||||
storageClass: obj.storage_class,
|
||||
isFolder: false,
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
bucket: data.bucket,
|
||||
objects,
|
||||
prefixes: [],
|
||||
count: data.count,
|
||||
isTruncated: data.is_truncated || false,
|
||||
nextContinuationToken: data.next_continuation_token,
|
||||
};
|
||||
},
|
||||
|
||||
get: async (bucket: string, key: string): Promise<Blob> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, {
|
||||
responseType: 'blob'
|
||||
@@ -349,8 +382,10 @@ export const objectsApi = {
|
||||
await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`);
|
||||
},
|
||||
|
||||
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
|
||||
const payload = { keys, ...(prefix && { prefix }) };
|
||||
// Deletes the given object keys and/or recursively deletes every object under
|
||||
// each folder prefix in a single request.
|
||||
deleteMultiple: async (bucket: string, keys: string[], prefixes: string[] = []): Promise<void> => {
|
||||
const payload = { keys, ...(prefixes.length > 0 && { prefixes }) };
|
||||
await api.post(`/v1/buckets/${bucket}/objects/delete-multiple`, payload);
|
||||
},
|
||||
|
||||
@@ -360,6 +395,12 @@ export const objectsApi = {
|
||||
});
|
||||
return response.data.data.url;
|
||||
},
|
||||
|
||||
getPreviewUrl: async (bucket: string, key: string): Promise<{ url: string; expiresAt: string }> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/preview-url`);
|
||||
const data = response.data.data;
|
||||
return { url: data.url, expiresAt: data.expires_at };
|
||||
},
|
||||
};
|
||||
|
||||
// Access Control API (Users/Keys)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { AccessKey } from '@/types';
|
||||
|
||||
export interface KeyPermissions {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
owner: boolean;
|
||||
}
|
||||
|
||||
export interface NewKeyRequest {
|
||||
name: string;
|
||||
permissions: KeyPermissions;
|
||||
}
|
||||
|
||||
export interface CreateBucketResult {
|
||||
bucket: 'ok' | 'failed';
|
||||
key?: { name: string; accessKeyId: string; secretKey: string };
|
||||
keyError?: string;
|
||||
grantError?: string;
|
||||
}
|
||||
|
||||
export interface CreateBucketDeps {
|
||||
createBucket: (name: string) => Promise<void>;
|
||||
createKey: (name: string) => Promise<AccessKey>;
|
||||
grant: (bucket: string, accessKeyId: string, permissions: KeyPermissions) => Promise<void>;
|
||||
}
|
||||
|
||||
const message = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/**
|
||||
* Bucket, then key, then grant, against three separate endpoints. There is no
|
||||
* rollback: whatever succeeded stays and the caller renders the partial
|
||||
* outcome. A failed grant still reports the key because the API returns the
|
||||
* secret exactly once.
|
||||
*/
|
||||
export async function createBucketWithKey(
|
||||
deps: CreateBucketDeps,
|
||||
bucketName: string,
|
||||
key?: NewKeyRequest,
|
||||
): Promise<CreateBucketResult> {
|
||||
try {
|
||||
await deps.createBucket(bucketName);
|
||||
} catch {
|
||||
return { bucket: 'failed' };
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return { bucket: 'ok' };
|
||||
}
|
||||
|
||||
let created: AccessKey;
|
||||
try {
|
||||
created = await deps.createKey(key.name);
|
||||
} catch (e) {
|
||||
return { bucket: 'ok', keyError: message(e) };
|
||||
}
|
||||
|
||||
const result: CreateBucketResult = {
|
||||
bucket: 'ok',
|
||||
key: {
|
||||
name: created.name || key.name,
|
||||
accessKeyId: created.accessKeyId,
|
||||
secretKey: created.secretKey ?? '',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await deps.grant(bucketName, created.accessKeyId, key.permissions);
|
||||
} catch (e) {
|
||||
result.grantError = message(e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,3 +1,27 @@
|
||||
import { objectsApi } from './api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Download an object from a bucket by fetching it as a blob and clicking a
|
||||
* temporary anchor element. Errors are surfaced by the axios interceptor.
|
||||
*/
|
||||
export async function downloadObject(bucket: string, key: string): Promise<void> {
|
||||
try {
|
||||
const blob = await objectsApi.get(bucket, key);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = key.split('/').pop() || '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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file type based on file extension
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highlight } from './highlight';
|
||||
|
||||
describe('highlight', () => {
|
||||
it('highlights a known language', () => {
|
||||
const html = highlight('{"a": 1}', 'json');
|
||||
expect(html).toContain('hljs-');
|
||||
});
|
||||
|
||||
it('escapes html in the source', () => {
|
||||
const html = highlight('<script>alert(1)</script>', 'xml');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('falls back to auto detection for null language', () => {
|
||||
const html = highlight('SELECT * FROM t;', null);
|
||||
expect(typeof html).toBe('string');
|
||||
expect(html.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('falls back to auto detection for an unregistered language', () => {
|
||||
const html = highlight('plain words', 'klingon');
|
||||
expect(typeof html).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Loaded only via dynamic import() from the text preview renderer, so
|
||||
// highlight.js never enters the main bundle.
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import dockerfile from 'highlight.js/lib/languages/dockerfile';
|
||||
import go from 'highlight.js/lib/languages/go';
|
||||
import ini from 'highlight.js/lib/languages/ini';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import markdown from 'highlight.js/lib/languages/markdown';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('dockerfile', dockerfile);
|
||||
hljs.registerLanguage('go', go);
|
||||
hljs.registerLanguage('ini', ini);
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('markdown', markdown);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
|
||||
// Returns highlighted HTML for the source text. hljs escapes the input, so
|
||||
// the output is safe to assign as innerHTML.
|
||||
export function highlight(text: string, language: string | null): string {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
return hljs.highlight(text, { language }).value;
|
||||
}
|
||||
return hljs.highlightAuto(text).value;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface Placement {
|
||||
/** Distance from the viewport top, when the popup opens downwards. */
|
||||
top?: number;
|
||||
/** Distance from the viewport bottom, when the popup flips above its trigger. */
|
||||
bottom?: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a floating layer against its trigger in viewport coordinates, for use
|
||||
* with `position: fixed` so no ancestor's overflow can clip it. Flips above the
|
||||
* trigger when the room below is too tight to be usable.
|
||||
*/
|
||||
export function placeUnder(
|
||||
rect: { top: number; bottom: number },
|
||||
viewportHeight: number,
|
||||
gap = 4,
|
||||
maxHeight = 240,
|
||||
): Placement {
|
||||
const spaceBelow = viewportHeight - rect.bottom - gap * 2;
|
||||
const spaceAbove = rect.top - gap * 2;
|
||||
const flip = spaceBelow < Math.min(maxHeight, 160) && spaceAbove > spaceBelow;
|
||||
|
||||
// Floor the height so a trigger at the very edge still shows a scrollable
|
||||
// popup rather than collapsing to nothing.
|
||||
const fit = (space: number) => Math.max(120, Math.min(maxHeight, space));
|
||||
|
||||
return flip
|
||||
? { bottom: viewportHeight - rect.top + gap, maxHeight: fit(spaceAbove) }
|
||||
: { top: rect.bottom + gap, maxHeight: fit(spaceBelow) };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getHighlightLanguage,
|
||||
getPreviewKind,
|
||||
getPreviewMime,
|
||||
getPreviewSizeLimit,
|
||||
looksBinary,
|
||||
IMAGE_PREVIEW_MAX_BYTES,
|
||||
PDF_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
} from './preview-utils';
|
||||
|
||||
describe('getPreviewKind', () => {
|
||||
it('trusts a specific content type first', () => {
|
||||
expect(getPreviewKind('image/png', 'noext')).toBe('image');
|
||||
expect(getPreviewKind('video/mp4', 'noext')).toBe('video');
|
||||
expect(getPreviewKind('audio/mpeg', 'noext')).toBe('audio');
|
||||
expect(getPreviewKind('application/pdf', 'noext')).toBe('pdf');
|
||||
expect(getPreviewKind('text/plain', 'noext')).toBe('text');
|
||||
expect(getPreviewKind('application/json', 'noext')).toBe('text');
|
||||
});
|
||||
|
||||
it('ignores content type parameters', () => {
|
||||
expect(getPreviewKind('text/plain; charset=utf-8', 'noext')).toBe('text');
|
||||
});
|
||||
|
||||
it('falls back to the extension when the type is octet-stream', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'photo.JPG')).toBe('image');
|
||||
expect(getPreviewKind('application/octet-stream', 'clip.mp4')).toBe('video');
|
||||
expect(getPreviewKind('application/octet-stream', 'song.flac')).toBe('audio');
|
||||
expect(getPreviewKind('application/octet-stream', 'doc.pdf')).toBe('pdf');
|
||||
expect(getPreviewKind('application/octet-stream', 'conf.yaml')).toBe('text');
|
||||
expect(getPreviewKind(undefined, 'a/b/c.json')).toBe('text');
|
||||
});
|
||||
|
||||
it('detects svg as image even though the backend rewrites its type', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'logo.svg')).toBe('image');
|
||||
});
|
||||
|
||||
it('detects Dockerfile without an extension', () => {
|
||||
expect(getPreviewKind(undefined, 'build/Dockerfile')).toBe('text');
|
||||
});
|
||||
|
||||
it('returns none for unknown files', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'blob.bin')).toBe('none');
|
||||
expect(getPreviewKind(undefined, 'noext')).toBe('none');
|
||||
expect(getPreviewKind(undefined, 'archive.zip')).toBe('none');
|
||||
});
|
||||
|
||||
it('does not treat a dotfile name as an extension', () => {
|
||||
expect(getPreviewKind(undefined, '.gitignore')).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviewSizeLimit', () => {
|
||||
it('caps documents per kind and leaves media unlimited', () => {
|
||||
expect(getPreviewSizeLimit('text')).toBe(TEXT_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('image')).toBe(IMAGE_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('pdf')).toBe(PDF_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('video')).toBeNull();
|
||||
expect(getPreviewSizeLimit('audio')).toBeNull();
|
||||
expect(getPreviewSizeLimit('none')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviewMime', () => {
|
||||
it('restores the mime type the backend rewrote', () => {
|
||||
expect(getPreviewMime('image', 'application/octet-stream', 'logo.svg')).toBe('image/svg+xml');
|
||||
expect(getPreviewMime('pdf', 'application/octet-stream', 'doc.pdf')).toBe('application/pdf');
|
||||
});
|
||||
it('keeps a usable content type when there is no mapping', () => {
|
||||
expect(getPreviewMime('text', 'text/plain', 'readme.txt')).toBe('text/plain');
|
||||
expect(getPreviewMime('text', undefined, 'readme.weird')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHighlightLanguage', () => {
|
||||
it('maps common extensions', () => {
|
||||
expect(getHighlightLanguage('a.json')).toBe('json');
|
||||
expect(getHighlightLanguage('a.yml')).toBe('yaml');
|
||||
expect(getHighlightLanguage('a.tsx')).toBe('typescript');
|
||||
expect(getHighlightLanguage('Dockerfile')).toBe('dockerfile');
|
||||
});
|
||||
it('returns null for unmapped extensions', () => {
|
||||
expect(getHighlightLanguage('a.log')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksBinary', () => {
|
||||
it('accepts ordinary text with newlines and tabs', () => {
|
||||
expect(looksBinary('hello\n\tworld\r\n')).toBe(false);
|
||||
expect(looksBinary('')).toBe(false);
|
||||
});
|
||||
it('flags replacement-heavy content', () => {
|
||||
expect(looksBinary('���ab')).toBe(true);
|
||||
});
|
||||
it('flags control-character-heavy content', () => {
|
||||
expect(looksBinary('\x00\x01\x02abc')).toBe(true);
|
||||
});
|
||||
it('tolerates a small fraction of oddities', () => {
|
||||
expect(looksBinary('a'.repeat(99) + '\x00')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'none';
|
||||
|
||||
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
||||
export const TEXT_HIGHLIGHT_MAX_BYTES = 1 * 1024 * 1024;
|
||||
export const IMAGE_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
|
||||
export const PDF_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const imageMimeByExtension: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
ico: 'image/x-icon',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const videoExtensions = new Set(['mp4', 'webm', 'ogv', 'mov']);
|
||||
const audioExtensions = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
|
||||
const textExtensions = new Set([
|
||||
'json', 'yaml', 'yml', 'ini', 'toml', 'xml', 'csv', 'md', 'log', 'txt',
|
||||
'conf', 'cfg', 'env', 'sh', 'bash', 'py', 'js', 'ts', 'jsx', 'tsx',
|
||||
'go', 'rs', 'java', 'c', 'h', 'cpp', 'hpp', 'sql', 'html', 'css', 'dockerfile',
|
||||
]);
|
||||
|
||||
function getExtension(key: string): string {
|
||||
const name = key.split('/').pop() ?? '';
|
||||
if (name.toLowerCase() === 'dockerfile') return 'dockerfile';
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
// Content-Type first, extension fallback. The fallback matters: Garage often
|
||||
// stores application/octet-stream, and the backend rewrites unsafe types to
|
||||
// it, so a generic type defers to the file extension.
|
||||
export function getPreviewKind(contentType: string | undefined, key: string): PreviewKind {
|
||||
const ct = (contentType ?? '').split(';')[0].trim().toLowerCase();
|
||||
if (ct && ct !== 'application/octet-stream') {
|
||||
if (ct.startsWith('image/')) return 'image';
|
||||
if (ct.startsWith('video/')) return 'video';
|
||||
if (ct.startsWith('audio/')) return 'audio';
|
||||
if (ct === 'application/pdf') return 'pdf';
|
||||
if (ct.startsWith('text/') || ct === 'application/json') return 'text';
|
||||
}
|
||||
const ext = getExtension(key);
|
||||
if (ext in imageMimeByExtension) return 'image';
|
||||
if (videoExtensions.has(ext)) return 'video';
|
||||
if (audioExtensions.has(ext)) return 'audio';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (textExtensions.has(ext)) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// Byte limit per kind. null means no limit because media streams with Range
|
||||
// requests and is never fully loaded into memory.
|
||||
export function getPreviewSizeLimit(kind: PreviewKind): number | null {
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return TEXT_PREVIEW_MAX_BYTES;
|
||||
case 'image':
|
||||
return IMAGE_PREVIEW_MAX_BYTES;
|
||||
case 'pdf':
|
||||
return PDF_PREVIEW_MAX_BYTES;
|
||||
case 'video':
|
||||
case 'audio':
|
||||
return null;
|
||||
case 'none':
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The backend rewrites unsafe Content-Types to application/octet-stream, so
|
||||
// blob URLs need their MIME type restored for the SVG <img> case and the
|
||||
// browser PDF viewer.
|
||||
export function getPreviewMime(kind: PreviewKind, contentType: string | undefined, key: string): string {
|
||||
if (kind === 'image') {
|
||||
return imageMimeByExtension[getExtension(key)] ?? contentType ?? 'application/octet-stream';
|
||||
}
|
||||
if (kind === 'pdf') return 'application/pdf';
|
||||
return contentType || 'application/octet-stream';
|
||||
}
|
||||
|
||||
const languageByExtension: Record<string, string> = {
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
ini: 'ini',
|
||||
toml: 'ini',
|
||||
xml: 'xml',
|
||||
md: 'markdown',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
py: 'python',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
go: 'go',
|
||||
sql: 'sql',
|
||||
html: 'xml',
|
||||
css: 'css',
|
||||
dockerfile: 'dockerfile',
|
||||
};
|
||||
|
||||
export function getHighlightLanguage(key: string): string | null {
|
||||
return languageByExtension[getExtension(key)] ?? null;
|
||||
}
|
||||
|
||||
// A text preview only makes sense for content that decodes as text. More
|
||||
// than 10 percent replacement or non-whitespace control characters in the
|
||||
// sample means the file is binary despite its name.
|
||||
export function looksBinary(sample: string): boolean {
|
||||
if (sample.length === 0) return false;
|
||||
let suspicious = 0;
|
||||
let total = 0;
|
||||
for (const ch of sample) {
|
||||
total++;
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
|
||||
suspicious++;
|
||||
}
|
||||
}
|
||||
return suspicious / total > 0.1;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { CredentialField } from '@/components/ui/credential-field';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,82 +33,9 @@ import {accessApi, bucketsApi} from '@/lib/api';
|
||||
import {queryKeys} from '@/lib/query-client';
|
||||
import {formatDate} from '@/lib/utils';
|
||||
import type {AccessKey, Bucket, BucketPermission} from '@/types';
|
||||
import {AlertTriangle, Calendar, Check, Copy, Database, Edit, Eye, EyeOff, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
|
||||
import {AlertTriangle, Calendar, Copy, Database, Edit, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
function CredentialField({
|
||||
label,
|
||||
value,
|
||||
mono = true,
|
||||
breakAll = false,
|
||||
maskable = false,
|
||||
loading = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
breakAll?: boolean;
|
||||
maskable?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [revealed, setRevealed] = useState(!maskable);
|
||||
const copy = () => {
|
||||
if (!value) return;
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
toast.success(`${label} copied`);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40));
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
disabled={loading || !value}
|
||||
title="Click to copy"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
|
||||
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
|
||||
mono && 'font-mono',
|
||||
breakAll ? 'break-all' : 'truncate',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
display
|
||||
)}
|
||||
</button>
|
||||
{maskable && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
aria-label={revealed ? 'Hide' : 'Reveal'}
|
||||
disabled={loading || !value}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" onClick={copy} aria-label={`Copy ${label}`} disabled={loading || !value}>
|
||||
{copied ? <Check className="h-4 w-4 text-[var(--primary)]" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessControl() {
|
||||
const queryClient = useQueryClient();
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
@@ -635,7 +563,7 @@ export function AccessControl() {
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -662,7 +590,7 @@ export function AccessControl() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle>API key created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy your secret access key now — this is the only time it will be shown.
|
||||
Copy your secret access key now, this is the only time it will be shown.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
@@ -742,7 +670,7 @@ export function AccessControl() {
|
||||
<div className="flex-1">
|
||||
<div className="text-[13.5px] font-medium">Grant bucket permissions now</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Optional — you can also do this later from the key's edit menu.
|
||||
Optional, you can also do this later from the key's edit menu.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -2,14 +2,23 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
||||
import { useBuckets } from '@/hooks/useApi';
|
||||
import { useBucketCan } from '@/hooks/usePermissions';
|
||||
|
||||
export function BucketObjects() {
|
||||
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { data: buckets = [] } = useBuckets();
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const canBucket = useBucketCan();
|
||||
const canWrite = canBucket(bucket, 'object.write');
|
||||
const canDelete = canBucket(bucket, 'object.delete');
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [deepSearch, setDeepSearch] = useState(false);
|
||||
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
|
||||
searchParams.get('page') ?? undefined,
|
||||
);
|
||||
@@ -27,6 +36,7 @@ export function BucketObjects() {
|
||||
|
||||
const {
|
||||
objects,
|
||||
debouncedSearch,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
@@ -40,10 +50,13 @@ export function BucketObjects() {
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
fetchObjects,
|
||||
} = useBucketObjects(bucketName, currentPath);
|
||||
} = useBucketObjects(bucketName, currentPath, searchQuery, deepSearch);
|
||||
|
||||
const handleNavigateToFolder = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
// Navigating to a folder should show that folder's contents, not a stale
|
||||
// filter carried over from the folder we came from.
|
||||
setSearchQuery('');
|
||||
const next = new URLSearchParams();
|
||||
if (path) next.set('prefix', path);
|
||||
setSearchParams(next);
|
||||
@@ -74,10 +87,12 @@ export function BucketObjects() {
|
||||
// CustomEvent bridge for the Upload button in BucketDetailShell hero.
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const handler = () => uploadInputRef.current?.click();
|
||||
const handler = () => {
|
||||
if (canWrite) uploadInputRef.current?.click();
|
||||
};
|
||||
document.addEventListener('bucket:upload', handler);
|
||||
return () => document.removeEventListener('bucket:upload', handler);
|
||||
}, []);
|
||||
}, [canWrite]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -97,18 +112,21 @@ export function BucketObjects() {
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
filterQuery={debouncedSearch}
|
||||
deepSearch={deepSearch}
|
||||
isLoading={isLoading}
|
||||
isTruncated={isTruncated}
|
||||
nextContinuationToken={nextContinuationToken}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onSearchChange={setSearchQuery}
|
||||
onDeepSearchChange={setDeepSearch}
|
||||
onNavigateToFolder={handleNavigateToFolder}
|
||||
onBackToBuckets={handleBackToBuckets}
|
||||
onUploadFiles={uploadFiles}
|
||||
onUploadFiles={canWrite ? uploadFiles : undefined}
|
||||
uploadTasks={uploadTasks}
|
||||
onDeleteObject={deleteObject}
|
||||
onDeleteMultipleObjects={deleteMultipleObjects}
|
||||
onCreateDirectory={createDirectory}
|
||||
onDeleteObject={canDelete ? deleteObject : undefined}
|
||||
onDeleteMultipleObjects={canDelete ? deleteMultipleObjects : undefined}
|
||||
onCreateDirectory={canWrite ? createDirectory : undefined}
|
||||
onRefresh={handleRefresh}
|
||||
onPageChange={handlePageChange}
|
||||
onItemsPerPageChange={handleItemsPerPageChange}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useBuckets, useDeleteBucket, useUpdateBucketQuotas } from '@/hooks/useApi';
|
||||
import { useBucketCan } from '@/hooks/usePermissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
@@ -86,6 +87,7 @@ export function BucketSettings() {
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const deleteMutation = useDeleteBucket();
|
||||
const updateQuotasMutation = useUpdateBucketQuotas();
|
||||
const canBucket = useBucketCan();
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -295,25 +297,27 @@ export function BucketSettings() {
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
|
||||
<header className="border-b border-[var(--danger-border)] px-5 py-3">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--destructive)]">Danger zone</h2>
|
||||
<p className="mt-0.5 text-[13.5px] text-[var(--muted-foreground)]">
|
||||
Destructive actions for this bucket.
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-medium">Delete bucket</div>
|
||||
<div className="text-[13.5px] text-[var(--muted-foreground)]">
|
||||
All objects in this bucket will be permanently removed.
|
||||
{canBucket(bucket, 'bucket.delete') && (
|
||||
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
|
||||
<header className="border-b border-[var(--danger-border)] px-5 py-3">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--destructive)]">Danger zone</h2>
|
||||
<p className="mt-0.5 text-[13.5px] text-[var(--muted-foreground)]">
|
||||
Destructive actions for this bucket.
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-medium">Delete bucket</div>
|
||||
<div className="text-[13.5px] text-[var(--muted-foreground)]">
|
||||
All objects in this bucket will be permanently removed.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
Delete bucket
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
Delete bucket
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DangerousConfirmDialog
|
||||
open={deleteOpen}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { accessApi, bucketsApi } from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-client';
|
||||
import { createBucketWithKey, type NewKeyRequest } from '@/lib/create-bucket-with-key';
|
||||
import { BucketListView } from '@/components/buckets/BucketListView';
|
||||
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
||||
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
||||
@@ -16,17 +21,41 @@ export function Buckets() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { hasAnyPerm } = usePermissions();
|
||||
const { data: buckets = [], isLoading } = useBuckets();
|
||||
const createMutation = useCreateBucket();
|
||||
const deleteMutation = useDeleteBucket();
|
||||
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({ name, region });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
// Both grant permissions are required by POST /buckets/:name/permissions, and
|
||||
// the bucket does not exist yet, so this is a best-effort check across the
|
||||
// subject's bindings. A 403 at call time surfaces in the dialog's outcome panel.
|
||||
const canCreateKey =
|
||||
hasAnyPerm('key.create') &&
|
||||
hasAnyPerm('permission.allow_bucket_key') &&
|
||||
hasAnyPerm('permission.deny_bucket_key');
|
||||
|
||||
const createBucket = async (name: string, key?: NewKeyRequest) => {
|
||||
const result = await createBucketWithKey(
|
||||
{
|
||||
createBucket: (n) => createMutation.mutateAsync({ name: n }),
|
||||
// Called directly, not through useCreateAccessKey and
|
||||
// useGrantBucketPermission: their success toasts would stack on top of
|
||||
// the dialog's own outcome panel.
|
||||
createKey: (n) => accessApi.createKey(n),
|
||||
grant: (bucket, accessKeyId, permissions) =>
|
||||
bucketsApi.grantPermission(bucket, accessKeyId, permissions),
|
||||
},
|
||||
name,
|
||||
key,
|
||||
);
|
||||
|
||||
if (result.key) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(name) });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
@@ -48,9 +77,11 @@ export function Buckets() {
|
||||
title="Buckets"
|
||||
subtitle={`${buckets.length} bucket${buckets.length === 1 ? '' : 's'}`}
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus /> Create bucket
|
||||
</Button>
|
||||
hasAnyPerm('bucket.create') && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus /> Create bucket
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="p-4 sm:p-6">
|
||||
@@ -70,6 +101,7 @@ export function Buckets() {
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreateBucket={createBucket}
|
||||
canCreateKey={canCreateKey}
|
||||
/>
|
||||
|
||||
<DangerousConfirmDialog
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
@@ -154,20 +155,25 @@ export function Dashboard() {
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--border)]">
|
||||
{buckets.slice(0, 5).map((bucket) => (
|
||||
<li key={bucket.name} className="flex items-center gap-3 py-3">
|
||||
<IconTile icon={<Database />} tone="primary" size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
|
||||
<p className="truncate text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
{bucket.size ? formatBytes(bucket.size) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<li key={bucket.name}>
|
||||
<Link
|
||||
to={`/buckets/${bucket.name}/objects`}
|
||||
className="-mx-2 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors hover:bg-[var(--muted)]"
|
||||
>
|
||||
<IconTile icon={<Database />} tone="primary" size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
|
||||
<p className="truncate text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
{bucket.size ? formatBytes(bucket.size) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ShieldOff } from 'lucide-react';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
/** Shown to authenticated users whose identity matches no team. */
|
||||
export function NoAccess() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-6 py-6">
|
||||
<EmptyState
|
||||
icon={<ShieldOff />}
|
||||
tone="neutral"
|
||||
title="You don't have access"
|
||||
description="Your account is signed in but isn't assigned to any team on this Garage UI. Contact your administrator to be added to a team."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Testing Library's automatic cleanup only self-registers when a global
|
||||
// afterEach exists. This harness does not set test.globals, so register it
|
||||
// explicitly to unmount rendered trees between tests.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export interface Bucket {
|
||||
errorDocument?: string;
|
||||
};
|
||||
quotas?: BucketQuotas | null;
|
||||
effective_permissions?: string[];
|
||||
}
|
||||
|
||||
export interface BucketDetails extends Bucket {
|
||||
@@ -238,6 +239,19 @@ export interface MultiNodeStatisticsResponse {
|
||||
error: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AccessControlBinding {
|
||||
bucket_prefixes: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface AccessControlInfo {
|
||||
enabled: boolean;
|
||||
subject?: string;
|
||||
is_admin?: boolean;
|
||||
bindings?: AccessControlBinding[];
|
||||
cluster_permissions?: string[];
|
||||
}
|
||||
|
||||
export interface GarageCapabilities {
|
||||
garageApiVersion: string;
|
||||
features: {
|
||||
@@ -245,6 +259,7 @@ export interface GarageCapabilities {
|
||||
nodeInfo: boolean;
|
||||
nodeStatistics: boolean;
|
||||
};
|
||||
access_control?: AccessControlInfo;
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: [
|
||||
'src/lib/preview-utils.ts',
|
||||
'src/lib/highlight.ts',
|
||||
'src/hooks/useObjectPreview.ts',
|
||||
'src/components/buckets/ObjectPreview.tsx',
|
||||
],
|
||||
thresholds: { statements: 90, branches: 90, functions: 90, lines: 90 },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.1](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.6.0...garage-ui-chart-v0.6.1) (2026-05-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Publish Helm chart to GHCR ([#70](https://github.com/Noooste/garage-ui/issues/70)) ([1c9043c](https://github.com/Noooste/garage-ui/commit/1c9043c6973c3ffc3fc29cc65b342b35dfa84ae0))
|
||||
|
||||
## [0.6.0](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.5.0...garage-ui-chart-v0.6.0) (2026-05-31)
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ name: garage-ui
|
||||
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
|
||||
icon: https://helm.noste.dev/garage.png
|
||||
type: application
|
||||
version: 0.6.0
|
||||
appVersion: v0.8.0
|
||||
version: 0.10.0 # x-release-please-version
|
||||
appVersion: v0.10.0 # x-release-please-version
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
|
||||
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml) <!-- x-release-please-version -->
|
||||
[](Chart.yaml) <!-- x-release-please-version -->
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -93,6 +93,28 @@ If you've cloned the repository:
|
||||
helm install garage-ui ./helm/garage-ui -f my-values.yaml
|
||||
```
|
||||
|
||||
### Installing from the OCI registry (ghcr.io)
|
||||
|
||||
The chart is published as an OCI artifact to GitHub Container Registry. No
|
||||
`helm repo add` is required:
|
||||
|
||||
```bash
|
||||
helm install garage-ui oci://ghcr.io/noooste/charts/garage-ui \
|
||||
--version <x.y.z> -f my-values.yaml
|
||||
```
|
||||
|
||||
The chart is signed with [cosign](https://docs.sigstore.dev/) using keyless
|
||||
signing. To verify the signature before installing:
|
||||
|
||||
```bash
|
||||
cosign verify ghcr.io/noooste/charts/garage-ui:<x.y.z> \
|
||||
--certificate-identity-regexp 'https://github.com/Noooste/garage-ui/.+' \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
```
|
||||
|
||||
The chart also remains available from the classic Helm repository at
|
||||
`https://helm.noste.dev`.
|
||||
|
||||
### Installing with inline values
|
||||
|
||||
You can also set values directly on the command line:
|
||||
@@ -203,6 +225,45 @@ config:
|
||||
# ... additional OIDC settings
|
||||
```
|
||||
|
||||
#### Multi-User Access Control (optional)
|
||||
|
||||
Scope what each OIDC user can see and do, based on the teams in their token
|
||||
claims. **Absent by default**, so every authenticated user keeps full access.
|
||||
When set, authorization becomes default-deny.
|
||||
|
||||
> **Not a security boundary.** This is UI-layer policy only. Anyone holding the
|
||||
> Garage admin token or raw S3 keys bypasses it. See
|
||||
> [docs/access-control.md](../../docs/access-control.md) for the full model and
|
||||
> permission vocabulary.
|
||||
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
oidc:
|
||||
enabled: true
|
||||
# OIDC claim (go-jmespath) listing the user's teams.
|
||||
team_attribute_path: "groups"
|
||||
# admin_role stays optional once access_control is set: unmatched users
|
||||
# are denied rather than promoted to admin.
|
||||
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 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]
|
||||
```
|
||||
|
||||
Admin-password and Garage-admin-token logins are always full admin in v1; only
|
||||
OIDC users can be scoped to a team.
|
||||
|
||||
#### CORS Configuration
|
||||
|
||||
```yaml
|
||||
@@ -628,16 +689,17 @@ Enable Prometheus metrics scraping (requires Prometheus Operator):
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
path: /api/v1/monitoring/metrics
|
||||
# /metrics is served only when config.auth.metrics_public is true
|
||||
path: /metrics
|
||||
labels:
|
||||
prometheus: kube-prometheus
|
||||
```
|
||||
|
||||
### Metrics Endpoint
|
||||
|
||||
The application exposes metrics at:
|
||||
- Path: `/api/v1/monitoring/metrics`
|
||||
- Format: Prometheus format (proxies Garage Admin API metrics)
|
||||
The application exposes Prometheus-format metrics (proxying the Garage Admin API) at:
|
||||
- `/api/v1/monitoring/metrics`: always registered, requires authentication.
|
||||
- `/metrics`: top-level, unauthenticated. Served ONLY when `config.auth.metrics_public` is `true`. Use this for Prometheus scraping when authentication is enabled, and restrict access with a NetworkPolicy / trusted scrape network.
|
||||
|
||||
### Health Checks
|
||||
|
||||
|
||||
@@ -199,6 +199,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"metrics_public": {
|
||||
"type": "boolean",
|
||||
"description": "Expose Prometheus metrics at top-level /metrics without authentication (required for scraping when auth is enabled). Restrict access with a NetworkPolicy.",
|
||||
"default": false
|
||||
},
|
||||
"admin": {
|
||||
"type": "object",
|
||||
"description": "Admin authentication settings (username/password)",
|
||||
@@ -322,11 +327,24 @@
|
||||
"description": "Path to roles in the OIDC token claims",
|
||||
"default": "resource_access.garage-ui.roles"
|
||||
},
|
||||
"team_attribute_path": {
|
||||
"type": "string",
|
||||
"description": "go-jmespath path to the team/group claim used by config.access_control (same convention as role_attribute_path). Required only when access_control.teams is set; empty disables team resolution",
|
||||
"default": ""
|
||||
},
|
||||
"admin_role": {
|
||||
"type": "string",
|
||||
"description": "Role name that grants admin privileges",
|
||||
"default": "admin"
|
||||
},
|
||||
"admin_roles": {
|
||||
"type": "array",
|
||||
"description": "Additional admin role names. A user is granted admin if ANY of their roles matches admin_role or any entry here",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"tls_skip_verify": {
|
||||
"type": "boolean",
|
||||
"description": "Skip TLS certificate verification (only for testing)",
|
||||
@@ -429,6 +447,77 @@
|
||||
"default": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"access_control": {
|
||||
"type": "object",
|
||||
"description": "Optional team-based access control (issue #33). When present (even empty) authorization is default-deny: OIDC users get only what their teams grant, and users matching no team are denied everywhere. When omitted, every authenticated user has full access. UI-layer policy only, NOT a security boundary: anyone holding the Garage admin token or S3 keys bypasses it. Requires auth.oidc.team_attribute_path when teams are set. See docs/access-control.md",
|
||||
"properties": {
|
||||
"presets": {
|
||||
"type": "object",
|
||||
"description": "Named, reusable permission lists referenced from bindings/cluster_permissions via the 'preset:<name>' syntax. Presets may reference other presets",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"teams": {
|
||||
"type": "array",
|
||||
"description": "Maps IdP claim values to bucket-prefix bindings and cluster-level permissions",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "claim_values"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique team name"
|
||||
},
|
||||
"claim_values": {
|
||||
"type": "array",
|
||||
"description": "Values matched (exact string, no wildcards) against the team_attribute_path claim",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"bindings": {
|
||||
"type": "array",
|
||||
"description": "Prefix-scoped permission grants over buckets whose names match one of the prefixes",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["bucket_prefixes", "permissions"],
|
||||
"properties": {
|
||||
"bucket_prefixes": {
|
||||
"type": "array",
|
||||
"description": "Bucket-name prefixes this binding applies to. Use '*' to match every bucket",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"description": "Prefix-scoped permissions, preset references (preset:<name>), or trailing-star globs (e.g. bucket.*, object.*)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cluster_permissions": {
|
||||
"type": "array",
|
||||
"description": "Global (cluster-level) permissions granted to the team, e.g. cluster.status, cluster.health",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -762,7 +851,7 @@
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Metrics endpoint path",
|
||||
"default": "/api/v1/monitoring/metrics"
|
||||
"default": "/metrics"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user