Compare commits

...

31 Commits

Author SHA1 Message Date
garage-ui-release-bot[bot] 16dc2eb996 chore(main): release 0.9.0 (#90)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-11 17:59:34 +02:00
Noste 0d804fbd39 feat(oidc): add fine grained access control (#91)
* feat(access-control): fine grain tokens

* fix(docs): clean wording

* test(access-control): add tests for team extraction from access tokens and bucket info permissions

* test(vocabulary): add test for ExpandGlob function to reject non-glob patterns

* feat(helm): add multi-user access control documentation and schema support
2026-07-11 17:57:28 +02:00
Noste 3098e474f2 feat(backend,frontend): implement prefix and recursive substring search for bucket objects (#89)
* feat(search): implement recursive substring search for bucket objects

* fix: update key formatting to remove trailing slashes in ObjectsTable

* feat(search): implement debounced search functionality and improve UI text clarity

* test(objects): add test for handling search error response
2026-07-11 17:09:27 +02:00
garage-ui-release-bot[bot] b28e975a56 chore(main): release 0.8.5 (#86)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-03 11:13:32 +02:00
Noste 430d1a5d68 fix(frontend): add downloadObject function for downloading files from a bucket 2026-07-03 11:10:33 +02:00
dependabot[bot] 460793632e chore(deps): bump esbuild, @vitejs/plugin-react and vite in /frontend (#79)
Removes [esbuild](https://github.com/evanw/esbuild). It's no longer used after updating ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). These dependencies need to be updated together.


Removes `esbuild`

Updates `@vitejs/plugin-react` from 5.1.2 to 5.2.0
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/plugin-react@5.2.0/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.2.0/packages/plugin-react)

Updates `vite` from 7.3.2 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version:
  dependency-type: indirect
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.2.0
  dependency-type: direct:development
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:33:57 +02:00
dependabot[bot] f888b7c77c chore(deps): bump js-yaml from 4.1.1 to 4.2.0 in /frontend (#83)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.1...4.2.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.2.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:33:41 +02:00
dependabot[bot] ef118dc3a7 chore(deps): bump form-data from 4.0.5 to 4.0.6 in /frontend (#82)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.5 to 4.0.6.
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:33:20 +02:00
garage-ui-release-bot[bot] 34ba8ef851 chore(main): release 0.8.4 (#84)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-22 15:32:55 +02:00
Noste 1ffdfe83c8 fix(helm): track appVersion in release-please and fix badges 2026-06-22 15:28:41 +02:00
garage-ui-release-bot[bot] e0cd59dcdf chore(main): release 0.8.3 (#81)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-22 12:23:53 +02:00
Noooste 1c1e4d4b2b chore: update Helm chart version to 0.8.2 and adjust README badges
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-21 23:20:44 +02:00
Noooste ae245c8a31 Merge remote-tracking branch 'origin/main' 2026-06-21 22:58:38 +02:00
Noooste 28c186f3eb fix(helm): update version badges in README for Garage UI
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-21 22:58:32 +02:00
Noste 46aa3752c8 fix(backend): improve API version detection with retry logic for health probes
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-06-17 19:14:06 +02:00
garage-ui-release-bot[bot] d502dac457 chore: release main (#77)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.8.2

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-08 00:35:53 +02:00
dependabot[bot] 67d8f633b0 chore(deps): bump react-router and react-router-dom in /frontend (#74)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.16.0 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 7.14.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.16.0/packages/react-router)

Updates `react-router-dom` from 7.14.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.16.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.16.0
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 00:13:10 +02:00
Noste 22be89b2ff fix(backend): prevent OIDC login loop from empty cookie name (#76)
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-08 00:11:45 +02:00
garage-ui-release-bot[bot] ae97dd8f01 chore: release main (#73)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.8.1

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-01 00:54:09 +02:00
Florian Gareis 45f8770799 fix(frontend): align three-dot menu item icon spacing and text alignment (#72) 2026-06-01 00:53:22 +02:00
Noste e3191c2686 fix(ci): add workflow_dispatch
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:57:58 +02:00
Noste 24997db960 fix(ci): add docker login for cosign
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:55:40 +02:00
Noste 3c69cc5f26 Merge remote-tracking branch 'origin/main' 2026-05-31 12:52:34 +02:00
Noste 4b0e98008b chore(release): remove pin version
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:52:04 +02:00
garage-ui-release-bot[bot] bc67e50606 chore: release main (#71)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-31 12:51:23 +02:00
Noste fe1765597c chore(release): exclude .github from app, pin chart to 0.6.1 patch
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:50:15 +02:00
Noste 1c9043c697 feat: Publish Helm chart to GHCR (#70)
* feat(ci): add GitHub Actions workflow to publish Helm chart to GHCR

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

* docs(helm): update README to include installation instructions from OCI registry and signature verification

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

---------

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:31:53 +02:00
garage-ui-release-bot[bot] b80cfef844 chore: release main (#69)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.8.0

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-31 11:57:40 +02:00
Noste 186af18d54 feat(docs): add documentation generation command to Makefile
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 11:54:43 +02:00
dependabot[bot] 4b3a562acb chore(deps): bump axios from 1.15.2 to 1.16.0 in /frontend (#67)
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 11:39:32 +02:00
Alistair Young 5427758eaa feat(backend,helm)!: bind to IPv6 wildcard by default for dual-stack support
* fix: Enable Garage UI to work on IPv6-based clusters.

* fix: building on nonstandard-uid machines.

* fix: Enable Garage UI to work on IPv6-based clusters.
2026-05-31 11:39:04 +02:00
74 changed files with 4803 additions and 1239 deletions
+46 -2
View File
@@ -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 }}"
-30
View File
@@ -25,33 +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
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
+1
View File
@@ -65,5 +65,6 @@ backend/docs/
config.yaml
docs/**/*.md
!docs/garage-setup.md
!docs/access-control.md
**/worktrees
+1 -2
View File
@@ -1,4 +1,3 @@
{
".": "0.7.0",
"helm/garage-ui": "0.5.0"
".": "0.9.0"
}
+56
View File
@@ -1,5 +1,61 @@
# Changelog
## [0.9.0](https://github.com/Noooste/garage-ui/compare/v0.8.5...v0.9.0) (2026-07-11)
### Features
* **backend,frontend:** implement prefix and recursive substring search for bucket objects ([#89](https://github.com/Noooste/garage-ui/issues/89)) ([3098e47](https://github.com/Noooste/garage-ui/commit/3098e474f202f36b527c8636cd1d4e8c08e287d2))
* **oidc:** add fine grained access control ([#91](https://github.com/Noooste/garage-ui/issues/91)) ([0d804fb](https://github.com/Noooste/garage-ui/commit/0d804fbd39ed656511b671d237ad14fc8c21786d))
## [0.8.5](https://github.com/Noooste/garage-ui/compare/v0.8.4...v0.8.5) (2026-07-03)
### Bug Fixes
* **frontend:** add downloadObject function for downloading files from a bucket ([430d1a5](https://github.com/Noooste/garage-ui/commit/430d1a5d68e4f9915a2951d5b31c1b5ea56cb24c))
## [0.8.4](https://github.com/Noooste/garage-ui/compare/v0.8.3...v0.8.4) (2026-06-22)
### Bug Fixes
* **helm:** track appVersion in release-please and fix badges ([1ffdfe8](https://github.com/Noooste/garage-ui/commit/1ffdfe83c884dc5e39d071343d5269164746c536))
## [0.8.3](https://github.com/Noooste/garage-ui/compare/v0.8.2...v0.8.3) (2026-06-21)
### Bug Fixes
* **backend:** improve API version detection with retry logic for health probes ([46aa375](https://github.com/Noooste/garage-ui/commit/46aa3752c81788787388d1c67e29cef786bdabff))
* **helm:** update version badges in README for Garage UI ([28c186f](https://github.com/Noooste/garage-ui/commit/28c186f3eba1a1c111100712f1eaec22a5d18eb2))
## [0.8.2](https://github.com/Noooste/garage-ui/compare/v0.8.1...v0.8.2) (2026-06-07)
### Bug Fixes
* **backend:** prevent OIDC login loop from empty cookie name ([#76](https://github.com/Noooste/garage-ui/issues/76)) ([22be89b](https://github.com/Noooste/garage-ui/commit/22be89b2ff86465abb90dab0344ef9366ab181b3))
## [0.8.1](https://github.com/Noooste/garage-ui/compare/v0.8.0...v0.8.1) (2026-05-31)
### Bug Fixes
* **frontend:** align three-dot menu item icon spacing and text alignment ([#72](https://github.com/Noooste/garage-ui/issues/72)) ([45f8770](https://github.com/Noooste/garage-ui/commit/45f87707996e92d0f8f75e79c8f60a13556eaf6e))
## [0.8.0](https://github.com/Noooste/garage-ui/compare/v0.7.0...v0.8.0) (2026-05-31)
### ⚠ BREAKING CHANGES
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support
### Features
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support ([5427758](https://github.com/Noooste/garage-ui/commit/5427758eaadc4fa1327402b958b7e7e1f43aecdd))
* **docs:** add documentation generation command to Makefile ([186af18](https://github.com/Noooste/garage-ui/commit/186af18d54f739bd9b467cbf3ab9ccc2e92ddf62))
## [0.7.0](https://github.com/Noooste/garage-ui/compare/v0.6.2...v0.7.0) (2026-05-23)
+1 -2
View File
@@ -48,7 +48,7 @@ RUN addgroup -g 1000 garageui && \
adduser -D -u 1000 -G garageui garageui
COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui .
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
COPY --from=frontend-builder --chown=garageui:garageui /app/frontend/dist ./frontend/dist
USER garageui
@@ -58,4 +58,3 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./garage-ui"]
+6
View File
@@ -154,3 +154,9 @@ test-cover:
## test-smoke: Run the docker compose smoke test (requires Docker + compose v2)
test-smoke:
cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/...
docs:
@echo "Generating documentation..."
@mkdir -p docs
@echo "Documentation generated in the 'docs' directory."
swag init -g backend/cmd/garage-ui/main.go -o docs --parseDependency --parseInternal
+11
View File
@@ -30,6 +30,7 @@ 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
## Quick Start
@@ -127,6 +128,16 @@ garage:
region: "garage"
```
Server bind host is configured by `server.host` (default: `::`). IPv6 literals like `::` and `::1` are supported.
```yaml
server:
host: "::" # IPv6 wildcard (dual-stack-preferred)
port: 8080
```
If your environment needs explicit IPv4-only binding, set `server.host: "0.0.0.0"`.
See [config.example.yaml](config.example.yaml) for all options including authentication, CORS, and logging.
### Environment Variables
+61 -9
View File
@@ -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
}
+75
View File
@@ -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)
}
}
+23 -3
View File
@@ -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)
}
}
+12 -8
View File
@@ -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),
+129
View File
@@ -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
}
+117
View File
@@ -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
}
+235
View File
@@ -0,0 +1,235 @@
package authz
import (
"fmt"
"reflect"
"runtime"
"strconv"
"strings"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/models"
logpkg "Noooste/garage-ui/pkg/logger"
"github.com/gofiber/fiber/v3"
)
// SubjectLocalsKey is the fiber.Ctx.Locals key carrying the resolved Subject.
const SubjectLocalsKey = "authzSubject"
// Middleware wires the policy into Fiber. When the policy is disabled
// (access_control absent) every handler is a passthrough no-op.
type Middleware struct {
enabled bool
resolver TeamResolver
authorizer Authorizer
}
func NewMiddleware(policy *Policy, resolver TeamResolver, authorizer Authorizer) *Middleware {
return &Middleware{enabled: policy.Enabled, resolver: resolver, authorizer: authorizer}
}
// Enabled reports whether access control is active.
func (m *Middleware) Enabled() bool { return m.enabled }
// ResolveSubject computes the request's Subject once, right after
// authentication. Handlers and the capabilities endpoint read the same
// struct, so enforcement and UI can never disagree.
func (m *Middleware) ResolveSubject() fiber.Handler {
return func(c fiber.Ctx) error {
if !m.enabled {
return c.Next()
}
userInfo, ok := c.Locals("userInfo").(*auth.UserInfo)
if !ok || userInfo == nil {
return c.Next() // no identity (auth disabled); Require will deny
}
c.Locals(SubjectLocalsKey, m.resolver.Resolve(userInfo))
return c.Next()
}
}
// SubjectFrom returns the Subject resolved for this request, if any.
func SubjectFrom(c fiber.Ctx) (Subject, bool) {
subj, ok := c.Locals(SubjectLocalsKey).(Subject)
return subj, ok
}
// ScopeResolver extracts the target Resource from the request.
type ScopeResolver func(c fiber.Ctx) Resource
// ScopeNone is for global permissions and unscoped list endpoints.
func ScopeNone(fiber.Ctx) Resource { return Resource{} }
// BucketFromParam reads the bucket name from a URL parameter.
func BucketFromParam(param string) ScopeResolver {
return func(c fiber.Ctx) Resource {
return Resource{Bucket: c.Params(param)}
}
}
// BucketFromBody reads the bucket name from a JSON body {"name": "..."}.
// Fiber buffers the body, so the handler can bind it again afterwards.
func BucketFromBody() ScopeResolver {
return func(c fiber.Ctx) Resource {
var req struct {
Name string `json:"name"`
}
if err := c.Bind().JSON(&req); err != nil {
return Resource{}
}
return Resource{Bucket: req.Name}
}
}
// Require gates a route on the caller holding ALL of perms for the resolved
// resource. One structured decision log line is emitted per check: denies at
// warn, allows at debug.
//
// The closure's function name is the marker VerifyRouteCoverage looks for.
// Do not wrap it in another anonymous function.
func (m *Middleware) Require(scope ScopeResolver, perms ...string) fiber.Handler {
if len(perms) == 0 {
// A no-perms Require would silently no-op (allow everything) yet still
// satisfy VerifyRouteCoverage, defeating the fail-closed guarantee.
panic("authz.Require: at least one permission required")
}
for _, p := range perms {
if !IsValidPermission(p) {
panic(fmt.Sprintf("authz.Require: unknown permission %q", p)) // programmer error, fail at wiring time
}
}
return func(c fiber.Ctx) error {
if !m.enabled {
return c.Next()
}
subj, ok := SubjectFrom(c)
if !ok {
logDecision(c, "", strings.Join(perms, ","), "", false, "no_subject")
return forbidden(c, perms[0])
}
res := scope(c)
for _, perm := range perms {
d := m.authorizer.Decide(subj, perm, res)
logDecision(c, subj.ID, perm, res.Bucket, d.Allow, d.Reason)
if !d.Allow {
return forbidden(c, perm)
}
}
return c.Next()
}
}
func forbidden(c fiber.Ctx, perm string) error {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Missing permission: "+perm),
)
}
func logDecision(c fiber.Ctx, subject, action, resource string, allow bool, reason string) {
l := logpkg.FromCtx(c.Context())
evt := l.Debug()
if !allow {
evt = l.Warn()
}
decision := "allow"
if !allow {
decision = "deny"
}
evt.Str("subject", subject).
Str("action", action).
Str("resource", resource).
Str("decision", decision).
Str("reason", reason).
Msg("authz_decision")
}
// coverageExemptPaths are /api/v1 routes that intentionally carry no Require:
// health is unauthenticated, capabilities is the frontend's fail-closed
// source and returns only the caller's own permissions.
var coverageExemptPaths = map[string]struct{}{
"/api/v1/health": {},
"/api/v1/capabilities": {},
}
// VerifyRouteCoverage walks the app's route table and errors if any /api/v1
// route lacks a Require handler. Called at startup (and from tests): a new
// endpoint registered without declaring its permission prevents boot instead
// of silently failing open.
//
// .Use()-registered routes need special handling. Group-level middleware
// (api.Use(handler) on the "/api/v1" group) produces synthetic per-method
// bookkeeping entries at exactly the bare prefix. Those aren't endpoints and
// are exempt. But a use-route at any deeper path (api.Use("/sneaky",
// terminalHandler)) IS a reachable endpoint and gets the same fail-closed
// treatment as normal routes.
func VerifyRouteCoverage(app *fiber.App) error {
// fiber.Route doesn't export whether a route was .Use()-registered, so
// classify by diffing GetRoutes() (everything) against GetRoutes(true)
// (use-routes filtered out): entries unmatched in the filtered multiset
// are use-registered.
nonUse := map[string]int{}
for _, r := range app.GetRoutes(true) {
nonUse[routeKey(r)]++
}
var naked []string
for _, route := range app.GetRoutes() {
// Consume the multiset for every route, before any skip, so
// classification stays consistent across the whole table.
isUse := true
if k := routeKey(route); nonUse[k] > 0 {
nonUse[k]--
isUse = false
}
if !strings.HasPrefix(route.Path, "/api/v1") {
continue
}
if isUse && route.Path == "/api/v1" {
continue // group-middleware bookkeeping at the bare prefix
}
if _, exempt := coverageExemptPaths[route.Path]; exempt {
continue
}
if route.Method == fiber.MethodHead && hasRequireForPath(app, fiber.MethodGet, route.Path) {
continue // Fiber auto-registers HEAD mirroring GET
}
if !routeHasRequire(route.Handlers) {
naked = append(naked, route.Method+" "+route.Path)
}
}
if len(naked) > 0 {
return fmt.Errorf("authz: routes without Require permission declaration: %s", strings.Join(naked, ", "))
}
return nil
}
// routeKey identifies a route for the use-route diff. Method+Path+handler
// count is robust enough: two routes sharing all three are interchangeable
// for coverage purposes, and the multiset keeps counts honest.
func routeKey(r fiber.Route) string {
return r.Method + " " + r.Path + " " + strconv.Itoa(len(r.Handlers))
}
func hasRequireForPath(app *fiber.App, method, path string) bool {
for _, route := range app.GetRoutes(true) {
if route.Method == method && route.Path == path {
return routeHasRequire(route.Handlers)
}
}
return false
}
func routeHasRequire(handlers []fiber.Handler) bool {
for _, h := range handlers {
fn := runtime.FuncForPC(fiberHandlerPC(h))
if fn != nil && strings.Contains(fn.Name(), "authz.(*Middleware).Require") {
return true
}
}
return false
}
func fiberHandlerPC(h fiber.Handler) uintptr {
return reflect.ValueOf(h).Pointer()
}
+235
View File
@@ -0,0 +1,235 @@
package authz
import (
"io"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"github.com/gofiber/fiber/v3"
)
func middlewareFixture(t *testing.T) *Middleware {
t.Helper()
policy, err := CompilePolicy(&config.AccessControlConfig{
Teams: []config.TeamConfig{{
Name: "backend",
ClaimValues: []string{"g-backend"},
Bindings: []config.BindingConfig{{
BucketPrefixes: []string{"backend-"},
Permissions: []string{"bucket.read", "bucket.create", "object.read"},
}},
ClusterPermissions: []string{"cluster.status"},
}},
})
if err != nil {
t.Fatal(err)
}
return NewMiddleware(policy, NewTeamResolver(policy, []string{"garage-admin"}), NewAuthorizer())
}
func newTestApp(m *Middleware, userInfo *auth.UserInfo) *fiber.App {
app := fiber.New()
app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware
if userInfo != nil {
c.Locals("userInfo", userInfo)
}
return c.Next()
})
app.Use(m.ResolveSubject())
app.Get("/api/v1/buckets/:name", m.Require(BucketFromParam("name"), PermBucketRead), func(c fiber.Ctx) error {
return c.SendString("ok")
})
app.Post("/api/v1/buckets", m.Require(BucketFromBody(), PermBucketCreate), func(c fiber.Ctx) error {
return c.SendString("created")
})
app.Get("/api/v1/cluster/status", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error {
return c.SendString("ok")
})
return app
}
func doReq(t *testing.T, app *fiber.App, method, path, body string) int {
t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode
}
func TestRequireAllowsMatchingTeam(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 200 {
t.Errorf("matching bucket: status %d, want 200", code)
}
if code := doReq(t, app, "GET", "/api/v1/cluster/status", ""); code != 200 {
t.Errorf("cluster.status: status %d, want 200", code)
}
}
func TestRequireDeniesOutOfScope(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "GET", "/api/v1/buckets/data-warehouse", ""); code != 403 {
t.Errorf("non-matching bucket: status %d, want 403", code)
}
}
func TestRequireBucketFromBody(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"backend-new"}`); code != 200 {
t.Errorf("create with matching prefix: status %d, want 200", code)
}
if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"other-new"}`); code != 403 {
t.Errorf("create with foreign prefix: status %d, want 403", code)
}
}
func TestRequireDefaultDenyZeroTeamUser(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "z@x", AuthMethod: "oidc"})
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 {
t.Errorf("zero-team user: status %d, want 403", code)
}
}
func TestRequirePassthroughWhenDisabled(t *testing.T) {
policy, _ := CompilePolicy(nil)
m := NewMiddleware(policy, NewTeamResolver(policy, nil), NewAuthorizer())
// No userInfo at all. Disabled access control must not require a subject.
app := newTestApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/anything", ""); code != 200 {
t.Errorf("disabled: status %d, want 200", code)
}
}
func TestMiddlewareEnabledReflectsPolicy(t *testing.T) {
if !middlewareFixture(t).Enabled() {
t.Error("Enabled() = false for a configured policy, want true")
}
disabled, _ := CompilePolicy(nil)
m := NewMiddleware(disabled, NewTeamResolver(disabled, nil), NewAuthorizer())
if m.Enabled() {
t.Error("Enabled() = true for a nil (disabled) policy, want false")
}
}
func TestResolveSubjectWithoutUserInfoDenies(t *testing.T) {
// Enabled middleware, but auth set no userInfo local: ResolveSubject leaves
// no subject, and Require then denies for want of one.
m := middlewareFixture(t)
app := newTestApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 {
t.Errorf("enabled + no userInfo: status %d, want 403", code)
}
}
func TestRequireBucketFromBodyMalformedBody(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
// Malformed JSON: BucketFromBody's bind fails and returns an empty resource.
// An empty bucket is an unscoped check, so a team holding bucket.create in
// any binding is allowed (this is what proves the resource came back empty:
// a non-empty foreign bucket name would be denied instead).
if code := doReq(t, app, "POST", "/api/v1/buckets", "{not-json"); code != 200 {
t.Errorf("malformed body: status %d, want 200 (empty resource, any_binding)", code)
}
}
func TestRequireUnknownPermissionPanics(t *testing.T) {
m := middlewareFixture(t)
defer func() {
r := recover()
if r == nil {
t.Fatal("Require with an unknown permission: want panic, got none")
}
if msg, ok := r.(string); !ok || !strings.Contains(msg, "unknown permission") {
t.Errorf("panic value = %v, want message containing %q", r, "unknown permission")
}
}()
m.Require(ScopeNone, "bogus.permission")
}
func TestVerifyRouteCoverage(t *testing.T) {
m := middlewareFixture(t)
covered := fiber.New()
covered.Get("/api/v1/capabilities", func(c fiber.Ctx) error { return nil }) // exempt
covered.Get("/api/v1/health", func(c fiber.Ctx) error { return nil }) // exempt
covered.Get("/api/v1/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil })
covered.Get("/other", func(c fiber.Ctx) error { return nil }) // outside /api/v1
if err := VerifyRouteCoverage(covered); err != nil {
t.Errorf("covered app: %v, want nil", err)
}
uncovered := fiber.New()
uncovered.Get("/api/v1/naked", func(c fiber.Ctx) error { return nil })
err := VerifyRouteCoverage(uncovered)
if err == nil {
t.Fatal("uncovered app: want error, got nil")
}
if !strings.Contains(err.Error(), "/api/v1/naked") {
t.Errorf("error should name the naked route: %v", err)
}
}
func TestVerifyRouteCoverage_GroupUseBookkeepingExempt(t *testing.T) {
// Group-level .Use() middleware produces synthetic per-method entries at
// exactly the bare group prefix; those aren't endpoints and must not trip
// the coverage check as long as the real routes carry Require.
m := middlewareFixture(t)
app := fiber.New()
api := app.Group("/api/v1")
api.Use(func(c fiber.Ctx) error { return c.Next() }) // stand-in for AuthMiddleware
api.Use(m.ResolveSubject())
api.Get("/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil })
if err := VerifyRouteCoverage(app); err != nil {
t.Errorf("group-level Use at bare prefix should be exempt: %v", err)
}
}
func TestRequireZeroPermissionsPanics(t *testing.T) {
m := middlewareFixture(t)
defer func() {
r := recover()
if r == nil {
t.Fatal("Require with zero perms: want panic, got none")
}
msg, ok := r.(string)
if !ok || !strings.Contains(msg, "at least one permission required") {
t.Errorf("panic value = %v, want message containing %q", r, "at least one permission required")
}
}()
m.Require(ScopeNone)
}
func TestVerifyRouteCoverage_UseRegisteredEndpointFlagged(t *testing.T) {
// A .Use()-registered route at a DEEPER path under /api/v1 is a reachable
// endpoint (Fiber runs it for every method with that prefix); it must get
// the same fail-closed treatment as a normal route.
app := fiber.New()
api := app.Group("/api/v1")
api.Use(func(c fiber.Ctx) error { return c.Next() }) // bare-prefix middleware stays exempt
api.Use("/sneaky", func(c fiber.Ctx) error { return c.SendString("terminal") })
err := VerifyRouteCoverage(app)
if err == nil {
t.Fatal("use-registered endpoint under /api/v1: want error, got nil")
}
if !strings.Contains(err.Error(), "/api/v1/sneaky") {
t.Errorf("error should name /api/v1/sneaky: %v", err)
}
}
+212
View File
@@ -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
}
+201
View File
@@ -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))
}
}
+55
View File
@@ -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
}
+93
View File
@@ -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)
}
}
+136
View File
@@ -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
}
+122
View File
@@ -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)
}
}
+62 -14
View File
@@ -2,7 +2,9 @@ package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/spf13/viper"
@@ -12,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
@@ -79,6 +82,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"`
@@ -128,6 +132,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)
@@ -160,12 +191,16 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
viper.SetConfigType("yaml")
// Built-in defaults (lowest priority)
viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("server.host", "::")
viper.SetDefault("server.port", 8080)
viper.SetDefault("server.environment", "production")
viper.SetDefault("garage.force_path_style", true)
viper.SetDefault("logging.level", "info")
viper.SetDefault("logging.format", "text")
viper.SetDefault("auth.oidc.cookie_name", "garage_session")
viper.SetDefault("auth.oidc.cookie_http_only", true)
viper.SetDefault("auth.oidc.cookie_same_site", "lax")
viper.SetDefault("auth.oidc.session_max_age", 86400)
// If garage.toml path is provided, parse it and set values as viper
// defaults. Defaults sit below config-file and env-var values in viper's
@@ -210,6 +245,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)
@@ -263,6 +308,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")
@@ -368,13 +414,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")
}
}
@@ -383,7 +431,7 @@ func (c *Config) Validate() error {
// GetAddress returns the full server address (host:port)
func (c *Config) GetAddress() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
}
// IsDevelopment returns true if running in development mode
+240
View File
@@ -83,6 +83,9 @@ func TestLoad_EnvOnly_MissingFile(t *testing.T) {
if cfg.Server.Port != 9090 {
t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port)
}
if cfg.Server.Host != "::" {
t.Errorf("Server.Host = %q, want :: (default)", cfg.Server.Host)
}
if cfg.Garage.AdminToken != "env-token" {
t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken)
}
@@ -326,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) {
@@ -367,6 +381,8 @@ func TestGetAddress(t *testing.T) {
}{
{"localhost", 8080, "localhost:8080"},
{"0.0.0.0", 80, "0.0.0.0:80"},
{"::", 80, "[::]:80"},
{"::1", 443, "[::1]:443"},
{"", 443, ":443"},
}
for _, tc := range tests {
@@ -485,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
@@ -634,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
+4 -2
View File
@@ -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)
+24
View File
@@ -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")
}
}
+52 -5
View File
@@ -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
}
+87 -4
View File
@@ -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)
}
}
+22 -6
View File
@@ -20,13 +20,13 @@ import (
// served from the same origin as the API, any uploader could otherwise plant
// stored XSS by uploading a file with one of these Content-Types.
var unsafeInlineContentTypes = map[string]struct{}{
"text/html": {},
"application/xhtml+xml": {},
"image/svg+xml": {},
"application/xml": {},
"text/xml": {},
"text/html": {},
"application/xhtml+xml": {},
"image/svg+xml": {},
"application/xml": {},
"text/xml": {},
"application/javascript": {},
"text/javascript": {},
"text/javascript": {},
}
// safeContentType rewrites Content-Types that the browser would treat as
@@ -89,6 +89,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 +110,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")
+46
View File
@@ -120,6 +120,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) {
+4
View File
@@ -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
+4
View File
@@ -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
+57 -35
View File
@@ -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))
@@ -53,31 +55,34 @@ func SetupRoutes(
// Apply authentication middleware to all API routes
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
// Resolve the authz Subject once per request, right after authentication.
api.Use(az.ResolveSubject())
api.Get("/capabilities", capabilitiesHandler.GetCapabilities)
// Bucket routes
buckets := api.Group("/buckets")
{
buckets.Get("/", 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.
@@ -114,38 +119,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 +241,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 +258,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 +270,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 {
@@ -0,0 +1,231 @@
package routes
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
// newTestAppWithAuthz builds a fully-wired fiber.App via SetupRoutes, reusing
// newTestApp's fixture (disabled-policy authz middleware, admin auth enabled
// so every /api/v1 route is reachable), and returns the *fiber.App directly
// for route-table inspection.
func newTestAppWithAuthz(t *testing.T) *fiber.App {
t.Helper()
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
})
return f.App
}
// TestEveryAPIRouteDeclaresPermission is the CI-level fail-closed guarantee:
// a new /api/v1 route without an authz.Require declaration fails this test
// (and would also refuse to boot via the same check in main).
func TestEveryAPIRouteDeclaresPermission(t *testing.T) {
app := newTestAppWithAuthz(t)
if err := authz.VerifyRouteCoverage(app); err != nil {
t.Fatalf("route coverage: %v", err)
}
}
// newEnabledPolicyFixture builds a SetupRoutes app with an ENABLED
// access-control policy (one team, claim "g-t", bucket prefix "allowed-",
// permissions bucket.list + object.list + object.read) and returns the
// fixture plus a Bearer session token for a member of that team. Admin auth
// is enabled so AuthMiddleware accepts the Bearer JWT; the resolver trusts
// the signed AuthMethod claim ("oidc"), so the user resolves through the
// team policy, not as the synthetic admin.
func newEnabledPolicyFixture(t *testing.T) (*routeFixture, string) {
t.Helper()
cfg := &config.Config{
Server: config.ServerConfig{
Port: 8080,
Environment: "test",
},
Auth: config.AuthConfig{
Admin: config.AdminAuthConfig{
Enabled: true,
Username: "admin",
Password: "pw",
},
},
CORS: config.CORSConfig{},
AccessControl: &config.AccessControlConfig{
Teams: []config.TeamConfig{{
Name: "team-t",
ClaimValues: []string{"g-t"},
Bindings: []config.BindingConfig{{
BucketPrefixes: []string{"allowed-"},
Permissions: []string{"bucket.list", "object.list", "object.read"},
}},
}},
},
}
svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
policy, err := authz.CompilePolicy(cfg.AccessControl)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer())
admin := &mocks.AdminMock{}
s3 := &mocks.S3Mock{}
app := fiber.New()
SetupRoutes(
app,
cfg,
svc,
handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3),
handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
az,
)
token, err := svc.GenerateSessionToken(&auth.UserInfo{
Username: "team-user",
Email: "team-user@example.com",
Teams: []string{"g-t"},
AuthMethod: "oidc",
})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}, token
}
// TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks in the Fiber
// behavior the wildcard wiring relies on: the api group's .Use() middlewares
// (AuthMiddleware, ResolveSubject) cascade by path prefix onto the wildcard
// object routes registered directly on app, so those routes do NOT repeat
// ResolveSubject themselves. If the cascade ever stopped covering them, the
// allowed-bucket request below would 403 with reason no_subject instead of
// reaching the handler.
func TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade(t *testing.T) {
f, token := newEnabledPolicyFixture(t)
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil
}
do := func(method, path string) int {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", method, path, err)
}
return resp.StatusCode
}
// In-scope bucket + held permission (object.read) → the request passes
// Require and reaches the handler. This is the discriminating assertion:
// it can only succeed if the group-level ResolveSubject ran for this
// wildcard route (no subject → Require denies everything).
if code := do("GET", "/api/v1/buckets/allowed-data/objects/somekey"); code != 200 {
t.Errorf("GET allowed bucket: status = %d, want 200 (cascaded ResolveSubject + Require allow)", code)
}
// Out-of-scope bucket → default deny.
if code := do("GET", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
t.Errorf("GET denied bucket: status = %d, want 403", code)
}
// DELETE requires object.delete, which the team does not hold, so it is denied
// even on an in-scope bucket.
if code := do("DELETE", "/api/v1/buckets/allowed-data/objects/somekey"); code != 403 {
t.Errorf("DELETE allowed bucket without object.delete: status = %d, want 403", code)
}
// HEAD wildcard is denied on an out-of-scope bucket too.
if code := do("HEAD", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
t.Errorf("HEAD denied bucket: status = %d, want 403", code)
}
}
// TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions is the
// HTTP-level companion to handlers.TestListBuckets_MapsAliasesAndStats: it
// drives GET /api/v1/buckets through the full authz-wired route (not just the
// handler in isolation) for a team-scoped session, with the mocked admin
// service returning buckets both inside and outside the team's "allowed-"
// prefix. Only in-scope buckets should come back, each carrying the caller's
// effective_permissions.
func TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions(t *testing.T) {
f, token := newEnabledPolicyFixture(t)
f.Admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
return []models.ListBucketsResponseItem{
{ID: "id-a", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-a"}},
{ID: "id-b", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-b"}},
{ID: "id-x", Created: time.Unix(0, 0), GlobalAliases: []string{"denied-x"}},
}, nil
}
f.Admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: alias, Objects: 0, Bytes: 0}, nil
}
req := httptest.NewRequest("GET", "/api/v1/buckets", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.BucketListResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Data.Count != 2 {
t.Fatalf("count = %d, want 2 (denied-x filtered out): %+v", body.Data.Count, body.Data.Buckets)
}
seen := map[string]bool{}
for _, b := range body.Data.Buckets {
seen[b.Name] = true
if !strings.HasPrefix(b.Name, "allowed-") {
t.Errorf("bucket %q returned, want only allowed-* buckets", b.Name)
}
if len(b.EffectivePermissions) == 0 {
t.Errorf("bucket %q: effective_permissions missing", b.Name)
}
}
if !seen["allowed-a"] || !seen["allowed-b"] {
t.Errorf("missing expected buckets, got: %+v", body.Data.Buckets)
}
if seen["denied-x"] {
t.Error("denied-x should not be visible to a team without bucket.list on that prefix")
}
}
@@ -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)
}
}
+9 -1
View File
@@ -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,
@@ -68,7 +75,8 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2()),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
az,
)
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}
+41 -2
View File
@@ -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")
}
}
+1
View File
@@ -48,6 +48,7 @@ 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)
+16 -7
View File
@@ -23,14 +23,15 @@ var _ services.S3Storage = (*S3Mock)(nil)
// per-method function fields they care about; unset methods return
// s3NotConfigured.
type S3Mock struct {
ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
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)
GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error)
DeleteObjectFn func(ctx context.Context, bucketName, key string) error
GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error
UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct {
Key string
@@ -55,6 +56,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 {
+92 -7
View File
@@ -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
+121
View File
@@ -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)
}
}
}
+18 -2
View File
@@ -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,6 +141,15 @@ 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)
@@ -212,18 +222,24 @@ 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()
logger.Info().
Str("address", addr).
Str("network", fiber.NetworkTCP).
Str("health_endpoint", fmt.Sprintf("http://%s/health", addr)).
Str("api_docs", fmt.Sprintf("http://%s/api/v1/", addr)).
Msg("Server starting")
if err := app.Listen(addr); err != nil {
if err := app.Listen(addr, fiber.ListenConfig{ListenerNetwork: fiber.NetworkTCP}); err != nil {
logger.Fatal().Err(err).Msg("Failed to start server")
}
}()
+26 -1
View File
@@ -2,7 +2,7 @@
# Server configuration
server:
host: "0.0.0.0"
host: "::" # IPv6 wildcard; dual-stack behavior depends on OS/runtime socket settings
port: 8080
environment: "development" # development, production
domain: "localhost" # Domain name for the application
@@ -75,6 +75,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 +96,29 @@ auth:
cookie_http_only: true
cookie_same_site: "lax" # lax, strict, none
# Optional: team-based access control (issue #33).
# Absent -> every authenticated user has full access (historical behavior).
# Present -> default-deny: OIDC users get only what their teams grant; users
# matching no team get 403 everywhere. admin_role users, admin
# password logins, and token logins are always full-admin in v1.
# NOTE: this is UI-layer policy, NOT a security boundary. Anyone holding the
# Garage admin token or S3 keys bypasses it entirely.
#
# access_control:
# presets:
# bucket_readonly: [bucket.list, bucket.read, object.list, object.read]
# bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update,
# bucket.delete, object.write, object.delete]
# teams:
# - name: backend
# claim_values: ["garage-team-backend"] # matched against team_attribute_path claim
# bindings:
# - bucket_prefixes: ["backend-"]
# permissions: ["preset:bucket_owner"]
# - bucket_prefixes: ["shared-"]
# permissions: ["preset:bucket_readonly"]
# cluster_permissions: [cluster.status, cluster.health]
# CORS Configuration (for frontend)
cors:
enabled: true
+170
View File
@@ -0,0 +1,170 @@
# Multi-User Access Control
Garage UI can limit what each user sees and does, based on the teams in their OIDC claims.
It's optional. With no config, every authenticated user has full access, exactly like before.
## Not a security boundary
Read this before using access control for anything sensitive.
Garage UI talks to Garage with one admin token and one set of S3 keys. Access control lives in the UI only; Garage itself does not enforce it. Anyone holding the underlying admin token or raw S3 keys bypasses it completely.
Use it to give teams a convenient, scoped UI. Don't use it as a replacement for real per-tenant credentials or network isolation.
## Configuration
Two settings drive access control:
1. `team_attribute_path`: the OIDC claim that lists a user's teams.
2. `access_control`: maps teams to permissions.
```yaml
auth:
oidc:
# Existing keys unchanged. New:
team_attribute_path: "groups" # go-jmespath, same convention as role_attribute_path
access_control: # absent = full access for everyone; present = default-deny
presets:
bucket_readonly: [bucket.list, bucket.read, object.list, object.read]
bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update,
bucket.delete, object.write, object.delete]
teams:
- name: backend
claim_values: ["garage-team-backend"] # matched against the team_attribute_path claim
bindings:
- bucket_prefixes: ["backend-"]
permissions: ["preset:bucket_owner"]
- bucket_prefixes: ["shared-"]
permissions: ["preset:bucket_readonly"]
cluster_permissions: [cluster.status, cluster.health]
```
A few things to know:
- `team_attribute_path` is a [go-jmespath](https://github.com/jmespath/go-jmespath) expression evaluated against the OIDC claims, the same way `role_attribute_path` works. It's required when `access_control.teams` is set and OIDC is on. If it's missing, startup fails with a clear error.
- `access_control` can only be set in the config file. There's no environment variable for it, because nested team and binding lists don't fit flat `GARAGE_UI_*` variables.
- If `access_control` is present but OIDC is off, the server still starts but logs a warning. Without OIDC users the policy gates nothing, since admin-password and token logins are always full admin (see [Admin model](#admin-model)).
## How it works
### Default-deny
With `access_control` set, an OIDC user who matches no team gets a 403 on every `/api/v1` endpoint. The one exception is `GET /api/v1/capabilities`, which returns their (empty) permissions so the frontend can show a "no access" screen.
### Union of teams
A user who matches several teams gets everything those teams grant.
Bindings stay separate, though. Say one binding grants `read` on `backend-*` and another grants `write` on `data-*`. The user does not end up with both permissions on both prefixes. Each binding keeps its own prefixes and its own permissions.
### Prefix match
`bucket_prefixes` are plain string prefixes on bucket names (no globbing on the name itself). Use `"*"` to match every bucket.
### Presets
Reference a preset with the `preset:` prefix inside any `permissions` or `cluster_permissions` list, for example `"preset:bucket_owner"`. Presets can reference other presets. Unknown references and cycles both fail startup.
### Permission globs
A trailing-star glob like `bucket.*`, `object.*`, or `cluster.layout.*` expands against the permission vocabulary when config loads. Use scoped globs:
- `bucket.*`, `object.*` inside a binding's `permissions`
- `cluster.*`, `node.*`, `worker.*`, `block.*` under `cluster_permissions`
A bare `*` is technically a glob, but it almost always fails validation: it mixes prefix-scoped and global-scoped permissions, and a permission placed in the wrong scope is rejected at startup. Globs never include admin-only permissions, and in v1 there's no team-level way to grant those.
### Admin model
These identities become a synthetic admin:
- OIDC users with a configured `admin_role` / `admin_roles`
- all non-OIDC logins (admin-password, Garage admin token)
An admin gets every permission on every bucket, plus every cluster permission. Admins run through the same authorizer as any team; there's no `IsAdmin` shortcut that skips the check.
### Startup validation
The server refuses to start when the policy is invalid: an unknown permission, an unknown or cyclic preset, an admin-only permission granted to a team, a duplicate team name, a team with empty `claim_values`, or a team with no bindings and no cluster permissions.
It also refuses to start if any `/api/v1` route has no declared permission, so a route can never ship un-gated (see [Troubleshooting](#troubleshooting)).
## Not in v1
- **No non-OIDC team mapping.** Admin-password and Garage-admin-token logins are always full admin. Only OIDC users can be scoped to a team.
- **`ListKeys` is not filtered.** Anyone with `key.list` sees every access key. Everything past `key.list` / `key.read` is admin-only (`key.read_secret`, `key.create`, `key.import`, `key.update`, `key.delete`).
- **No `admin_token.*` permissions.** Direct access to the raw Garage admin token is admin-only and not part of the vocabulary.
- **No ABAC, policy language, database-backed policy, or per-user grants.** Policy is YAML, compiled once at startup.
## Permission vocabulary (v1)
Permission names are lowercase and dot-separated: two segments, or three for `cluster.layout.*`. The source of truth is `backend/internal/authz/vocabulary.go`. This table mirrors it by hand, and there's no doc generation in v1, so update the table whenever you change the registry.
| Permission | Scope | Admin-only v1 | Garage endpoint / backing |
|---|---|---|---|
| `bucket.list` | prefix | | ListBuckets (response-filtered) |
| `bucket.read` | prefix | | GetBucketInfo |
| `bucket.create` | prefix | | CreateBucket (new name must match a prefix) |
| `bucket.update` | prefix | | UpdateBucket |
| `bucket.delete` | prefix | | DeleteBucket |
| `bucket.cleanup_uploads` | prefix | | CleanupIncompleteUploads |
| `bucket.inspect_object` | prefix | | InspectObject |
| `bucket_alias.add` | prefix | | AddBucketAlias |
| `bucket_alias.remove` | prefix | | RemoveBucketAlias |
| `object.list` | prefix | | S3 data plane (ListObjects) |
| `object.read` | prefix | | S3 data plane (Get/Head/Metadata/Presign; presign is download-only) |
| `object.write` | prefix | | S3 data plane (Upload, CreateDirectory) |
| `object.delete` | prefix | | S3 data plane (Delete, DeleteMultiple) |
| `permission.allow_bucket_key` | prefix | | AllowBucketKey |
| `permission.deny_bucket_key` | prefix | | DenyBucketKey |
| `key.list` | global | | ListKeys (unfiltered in v1; grantee sees all keys) |
| `key.read` | global | | GetKeyInfo (without secret) |
| `key.read_secret` | global | yes | GetKeyInfo with secret material |
| `key.create` | global | yes | CreateKey |
| `key.import` | global | yes | ImportKey |
| `key.update` | global | yes | UpdateKey |
| `key.delete` | global | yes | DeleteKey |
| `cluster.status` | global | | GetClusterStatus |
| `cluster.health` | global | | GetClusterHealth |
| `cluster.statistics` | global | | GetClusterStatistics |
| `cluster.connect_nodes` | global | | ConnectClusterNodes |
| `cluster.layout.read` | global | | GetClusterLayout |
| `cluster.layout.history` | global | | GetClusterLayoutHistory |
| `cluster.layout.apply` | global | | ApplyClusterLayout |
| `cluster.layout.skip_dead_nodes` | global | | ClusterLayoutSkipDeadNodes |
| `node.info` | global | | GetNodeInfo |
| `node.statistics` | global | | GetNodeStatistics |
| `node.snapshot` | global | | CreateMetadataSnapshot |
| `node.repair` | global | | LaunchRepairOperation |
| `worker.list` | global | | ListWorkers |
| `worker.info` | global | | GetWorkerInfo |
| `worker.get_variable` | global | | GetWorkerVariable |
| `worker.set_variable` | global | | SetWorkerVariable |
| `block.list_errors` | global | | ListBlockErrors |
| `block.info` | global | | GetBlockInfo |
Some permissions have no UI route yet: `bucket.cleanup_uploads`, `bucket.inspect_object`, `bucket_alias.*`, `cluster.layout.*`, `worker.*`, `block.*`, `cluster.connect_nodes`, `node.snapshot`, `node.repair`, and `key.import`. They're valid in config but don't gate anything in the UI yet. The vocabulary is complete up front so your config keeps working as the UI grows.
Dangerous operations (`cluster.layout.apply`, `node.repair`, `worker.set_variable`) are separate, individually grantable permissions. They're never bundled into a read-only preset, so you can give a team cluster visibility without also giving it the power to break the cluster.
`POST /api/v1/buckets/:name/permissions` sets permissions by doing an allow and a deny in one call, so it needs **both** `permission.allow_bucket_key` and `permission.deny_bucket_key`. One of the two alone is not enough.
## Troubleshooting
**A user gets 403s they shouldn't.** Open `GET /api/v1/capabilities` while logged in as that user. The `access_control` block shows their resolved `bindings` and `cluster_permissions` (empty arrays mean they matched no team). Check that the IdP actually sends the claim named by `team_attribute_path`, and that its values match a team's `claim_values` exactly (string match, no wildcards on the claim value).
**403 responses name the missing permission.** The message is `Missing permission: <permission.name>`. That's the exact permission that was denied, so you know which binding, preset, or `cluster_permissions` entry to add.
**Decision logs.** Every check logs one line, `authz_decision`, with fields `subject`, `action`, `resource`, `decision` (`allow` / `deny`), and `reason` (such as `binding_match`, `any_binding`, `no_matching_binding`, `cluster_permission`, `no_cluster_permission`, `no_subject`). Denials log at `warn`, allows at `debug`. Set `logging.level` to `debug` to see successful checks too. There's no separate audit log in v1; this goes through the normal application logger.
**Startup fails with `access_control: ...` or `authz: routes without Require permission declaration: ...`.** Both are intentional fail-closed checks, not bugs:
- An invalid policy (unknown permission, bad preset reference, admin-only permission handed to a team, duplicate team name, empty `claim_values`, or a team with no bindings or cluster permissions) stops startup with an error naming the problem.
- A `/api/v1` route wired without a permission requirement also stops startup. This is a build-time safety net, not something you trigger by editing config, but it can show up after a `git pull` that adds a route without its enforcement wiring.
## See also
- [config.example.yaml](../config.example.yaml): the full commented `access_control` example.
- [garage-setup.md](garage-setup.md): general Garage UI and Garage setup.
+630 -863
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -14,7 +14,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.15.2",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -23,7 +23,7 @@
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.14.1",
"react-router-dom": "^7.16.0",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -36,7 +36,7 @@
"@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",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -46,6 +46,6 @@
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.3.2"
"vite": "^8.0.16"
}
}
@@ -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>
@@ -6,7 +6,7 @@ import {ObjectsTable} from './ObjectsTable';
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
import {DeleteObjectDialog} from './DeleteObjectDialog';
import {UploadProgress} from './UploadProgress';
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, 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 +15,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[]) => Promise<boolean>;
onCreateDirectory?: (name: string) => Promise<boolean>;
onRefresh: () => Promise<void>;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
@@ -41,11 +44,14 @@ export function ObjectBrowserView({
objects,
currentPath,
searchQuery,
filterQuery,
deepSearch,
isLoading = false,
isTruncated = false,
nextContinuationToken,
itemsPerPage,
onSearchChange,
onDeepSearchChange,
onNavigateToFolder,
onBackToBuckets,
onUploadFiles,
@@ -69,6 +75,8 @@ export function ObjectBrowserView({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles, _fileRejections, event) => {
if (!onUploadFiles) return;
// Get files with their full paths from DataTransferItems API
const filesWithPaths: File[] = [];
@@ -95,6 +103,7 @@ export function ObjectBrowserView({
setShowUploadZone(false);
},
noClick: true,
disabled: !onUploadFiles,
});
// Helper function to traverse file/directory tree
@@ -147,13 +156,14 @@ export function ObjectBrowserView({
};
const handleBulkDeleteFiles = async () => {
if (selectedFileKeys.size === 0) return;
if (!onDeleteMultipleObjects || selectedFileKeys.size === 0) return;
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
setSelectedFileKeys(new Set());
};
const handleDeleteObject = async (key: string): Promise<boolean> => {
if (!onDeleteObject) return false;
const success = await onDeleteObject(key);
if (success) {
setDeleteObjectDialogOpen(false);
@@ -163,6 +173,7 @@ export function ObjectBrowserView({
};
const uploadFiles = async (files: File[]) => {
if (!onUploadFiles) return;
await onUploadFiles(files);
setShowUploadZone(false);
};
@@ -199,17 +210,34 @@ 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 && selectedFileKeys.size > 0 && (
<Button
onClick={handleBulkDeleteFiles}
title={`Delete ${selectedFileKeys.size} selected file(s)`}
@@ -219,14 +247,18 @@ export function ObjectBrowserView({
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
</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>
{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" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
@@ -234,7 +266,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,6 +384,8 @@ export function ObjectBrowserView({
objects={objects}
currentPath={currentPath}
searchQuery={searchQuery}
filterQuery={filterQuery}
deepSearch={deepSearch}
selectedFileKeys={selectedFileKeys}
isDragActive={isDragActive}
isLoading={isLoading && !isRefreshing && !isNavigating}
@@ -359,10 +393,10 @@ export function ObjectBrowserView({
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onNavigateToFolder={onNavigateToFolder}
onDeleteObject={(obj) => {
onDeleteObject={onDeleteObject ? (obj) => {
setSelectedObject(obj);
setDeleteObjectDialogOpen(true);
}}
} : undefined}
onToggleFileSelection={handleToggleFileSelection}
onSelectAllFiles={handleSelectAllFiles}
onPageChange={onPageChange}
@@ -374,12 +408,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
@@ -1,6 +1,8 @@
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';
@@ -8,7 +10,7 @@ import { IconTile } from '@/components/ui/icon-tile';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { 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 +38,11 @@ export function ObjectDetailsView() {
const { bucketName, '*': encodedObjectKey } = useParams();
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
const { data: buckets = [] } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const canBucket = useBucketCan();
const canDelete = canBucket(bucket, 'object.delete');
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -73,22 +80,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 +168,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>
+100 -56
View File
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu';
import {ChevronLeft, ChevronRight, Download, Eye, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
import {Select, SelectOption} from '@/components/ui/select';
import {formatBytes, formatRelativeTime} from '@/lib/file-utils';
import {downloadObject, formatBytes, formatRelativeTime} from '@/lib/file-utils';
import type {S3Object} from '@/types';
interface ObjectsTableProps {
@@ -22,6 +22,8 @@ interface ObjectsTableProps {
objects: S3Object[];
currentPath: string;
searchQuery: string;
filterQuery: string;
deepSearch: boolean;
selectedFileKeys: Set<string>;
isDragActive: boolean;
isLoading?: boolean;
@@ -29,7 +31,7 @@ interface ObjectsTableProps {
nextContinuationToken?: string;
itemsPerPage: number;
onNavigateToFolder: (key: string) => void;
onDeleteObject: (object: S3Object) => void;
onDeleteObject?: (object: S3Object) => void;
onToggleFileSelection: (key: string) => void;
onSelectAllFiles: () => void;
onPageChange: (token?: string) => void;
@@ -46,6 +48,8 @@ export function ObjectsTable({
objects,
currentPath,
searchQuery,
filterQuery,
deepSearch,
selectedFileKeys,
isDragActive,
isLoading = false,
@@ -62,6 +66,7 @@ export function ObjectsTable({
initialItemsPerPage,
}: ObjectsTableProps) {
const navigate = useNavigate();
const canDelete = Boolean(onDeleteObject);
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
@@ -86,7 +91,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 +103,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 +121,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 +146,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 +181,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 +215,18 @@ 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
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
/>
</TableHead>
)}
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('name')}
@@ -217,7 +253,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 +262,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 +271,26 @@ 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
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
/>
) : (
<Checkbox
checked={selectedFileKeys.has(obj.key)}
onCheckedChange={() => onToggleFileSelection(obj.key)}
aria-label={`Select file ${obj.key}`}
/>
)}
</TableCell>
)}
<TableCell>
<div className="flex items-center gap-2">
{obj.isFolder ? (
@@ -265,7 +303,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
@@ -353,18 +391,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 +437,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}
+4 -1
View File
@@ -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>
+44 -35
View File
@@ -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
+1 -1
View File
@@ -151,7 +151,7 @@ const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<H
<div
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center 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
View File
@@ -1,2 +1,3 @@
export { useDashboardData, useBuckets } from './useApi';
export { useBucketObjects } from './useBucketObjects';
export { usePermissions, bucketCan, useBucketCan } from './usePermissions';
+66 -7
View File
@@ -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 () => {
@@ -194,6 +250,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,
+76
View File
@@ -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);
};
}
+33
View File
@@ -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'
+24
View File
@@ -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
*/
+3 -3
View File
@@ -635,7 +635,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 +662,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 +742,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>
+25 -7
View File
@@ -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}
+21 -17
View File
@@ -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}
+7 -3
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus } from 'lucide-react';
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
import { usePermissions } from '@/hooks/usePermissions';
import { BucketListView } from '@/components/buckets/BucketListView';
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
@@ -16,6 +17,7 @@ export function Buckets() {
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
const [deleting, setDeleting] = useState(false);
const { hasAnyPerm } = usePermissions();
const { data: buckets = [], isLoading } = useBuckets();
const createMutation = useCreateBucket();
const deleteMutation = useDeleteBucket();
@@ -48,9 +50,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">
+16
View File
@@ -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>
);
}
+15
View File
@@ -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 {
+18
View File
@@ -1,5 +1,23 @@
# 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)
### ⚠ BREAKING CHANGES
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support
### Features
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support ([5427758](https://github.com/Noooste/garage-ui/commit/5427758eaadc4fa1327402b958b7e7e1f43aecdd))
## [0.5.1](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.5.0...garage-ui-chart-v0.5.1) (2026-05-25)
* **bump AppVersion:** 0.6.2 -> 0.7.0
+2 -2
View File
@@ -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.5.1
appVersion: v0.7.0
version: 0.9.0 # x-release-please-version
appVersion: v0.9.0 # x-release-please-version
keywords:
- garage
- s3
+63 -2
View File
@@ -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.
[![Version](https://img.shields.io/badge/version-0.3.0-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.5.0-green.svg)](Chart.yaml)
[![Version](https://img.shields.io/badge/version-0.9.0?color=blue)](Chart.yaml) <!-- x-release-please-version -->
[![App Version](https://img.shields.io/badge/app%20version-v0.9.0?color=green)](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
+86 -2
View File
@@ -69,8 +69,8 @@
"properties": {
"host": {
"type": "string",
"description": "Network interface to bind to (0.0.0.0 for all interfaces)",
"default": "0.0.0.0"
"description": "Network interface to bind to (use :: for IPv6 wildcard / dual-stack-preferred, or 0.0.0.0 for IPv4 wildcard)",
"default": "::"
},
"port": {
"type": "integer",
@@ -322,11 +322,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 +442,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"
}
}
}
}
}
}
}
}
},
+21 -1
View File
@@ -36,7 +36,7 @@ extraObjects: []
config:
server:
host: "0.0.0.0"
host: "::"
port: 8080
environment: "production"
domain: "garage-ui.example.com"
@@ -113,6 +113,11 @@ config:
role_attribute_path: "resource_access.garage-ui.roles"
admin_role: "admin"
admin_roles: []
# Team-based access control (optional). OIDC claim (go-jmespath, same
# convention as role_attribute_path) listing the user's teams. Required
# only when config.access_control.teams is set; leave empty to disable.
# See config.access_control below and docs/access-control.md.
team_attribute_path: ""
# TLS settings
tls_skip_verify: false
# Session settings
@@ -147,6 +152,21 @@ config:
# Options: json, text
format: "json"
# 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]
# Pod annotations
podAnnotations: {}
+4 -9
View File
@@ -2,21 +2,16 @@
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"separate-pull-requests": false,
"packages": {
".": {
"release-type": "go",
"component": "garage-ui",
"include-component-in-tag": false,
"changelog-path": "CHANGELOG.md",
"exclude-paths": ["helm/garage-ui"]
},
"helm/garage-ui": {
"release-type": "helm",
"component": "garage-ui-chart",
"package-name": "garage-ui",
"include-component-in-tag": true,
"changelog-path": "CHANGELOG.md"
"extra-files": [
{ "type": "generic", "path": "helm/garage-ui/Chart.yaml" },
"helm/garage-ui/README.md"
]
}
}
}