Files
garage-ui/backend/internal/routes/routes_authz_test.go
T
Noste c0976a1e1d feat(preview): Implement object preview functionality (#94)
* feat(preview): implement object preview functionality with token-based access

* docs: update README to include inline object preview feature

* test: add range read error handling and attachment content disposition tests
2026-07-15 00:01:04 +02:00

303 lines
11 KiB
Go

package routes
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
// newTestAppWithAuthz builds a fully-wired fiber.App via SetupRoutes, reusing
// newTestApp's fixture (disabled-policy authz middleware, admin auth enabled
// so every /api/v1 route is reachable), and returns the *fiber.App directly
// for route-table inspection.
func newTestAppWithAuthz(t *testing.T) *fiber.App {
t.Helper()
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
})
return f.App
}
// TestEveryAPIRouteDeclaresPermission is the CI-level fail-closed guarantee:
// a new /api/v1 route without an authz.Require declaration fails this test
// (and would also refuse to boot via the same check in main).
func TestEveryAPIRouteDeclaresPermission(t *testing.T) {
app := newTestAppWithAuthz(t)
if err := authz.VerifyRouteCoverage(app); err != nil {
t.Fatalf("route coverage: %v", err)
}
}
// newEnabledPolicyFixture builds a SetupRoutes app with an ENABLED
// access-control policy (one team, claim "g-t", bucket prefix "allowed-",
// permissions bucket.list + object.list + object.read) and returns the
// fixture plus a Bearer session token for a member of that team. Admin auth
// is enabled so AuthMiddleware accepts the Bearer JWT; the resolver trusts
// the signed AuthMethod claim ("oidc"), so the user resolves through the
// team policy, not as the synthetic admin.
func newEnabledPolicyFixture(t *testing.T) (*routeFixture, string) {
t.Helper()
cfg := &config.Config{
Server: config.ServerConfig{
Port: 8080,
Environment: "test",
},
Auth: config.AuthConfig{
Admin: config.AdminAuthConfig{
Enabled: true,
Username: "admin",
Password: "pw",
},
},
CORS: config.CORSConfig{},
AccessControl: &config.AccessControlConfig{
Teams: []config.TeamConfig{{
Name: "team-t",
ClaimValues: []string{"g-t"},
Bindings: []config.BindingConfig{{
BucketPrefixes: []string{"allowed-"},
Permissions: []string{"bucket.list", "object.list", "object.read"},
}},
}},
},
}
svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
policy, err := authz.CompilePolicy(cfg.AccessControl)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer())
admin := &mocks.AdminMock{}
s3 := &mocks.S3Mock{}
app := fiber.New()
SetupRoutes(
app,
cfg,
svc,
handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3, svc),
handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
az,
)
token, err := svc.GenerateSessionToken(&auth.UserInfo{
Username: "team-user",
Email: "team-user@example.com",
Teams: []string{"g-t"},
AuthMethod: "oidc",
})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}, token
}
// TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks in the Fiber
// behavior the wildcard wiring relies on: the api group's .Use() middlewares
// (AuthMiddleware, ResolveSubject) cascade by path prefix onto the wildcard
// object routes registered directly on app, so those routes do NOT repeat
// ResolveSubject themselves. If the cascade ever stopped covering them, the
// allowed-bucket request below would 403 with reason no_subject instead of
// reaching the handler.
func TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade(t *testing.T) {
f, token := newEnabledPolicyFixture(t)
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil
}
do := func(method, path string) int {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", method, path, err)
}
return resp.StatusCode
}
// In-scope bucket + held permission (object.read) → the request passes
// Require and reaches the handler. This is the discriminating assertion:
// it can only succeed if the group-level ResolveSubject ran for this
// wildcard route (no subject → Require denies everything).
if code := do("GET", "/api/v1/buckets/allowed-data/objects/somekey"); code != 200 {
t.Errorf("GET allowed bucket: status = %d, want 200 (cascaded ResolveSubject + Require allow)", code)
}
// Out-of-scope bucket → default deny.
if code := do("GET", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
t.Errorf("GET denied bucket: status = %d, want 403", code)
}
// DELETE requires object.delete, which the team does not hold, so it is denied
// even on an in-scope bucket.
if code := do("DELETE", "/api/v1/buckets/allowed-data/objects/somekey"); code != 403 {
t.Errorf("DELETE allowed bucket without object.delete: status = %d, want 403", code)
}
// HEAD wildcard is denied on an out-of-scope bucket too.
if code := do("HEAD", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
t.Errorf("HEAD denied bucket: status = %d, want 403", code)
}
}
// TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions is the
// HTTP-level companion to handlers.TestListBuckets_MapsAliasesAndStats: it
// drives GET /api/v1/buckets through the full authz-wired route (not just the
// handler in isolation) for a team-scoped session, with the mocked admin
// service returning buckets both inside and outside the team's "allowed-"
// prefix. Only in-scope buckets should come back, each carrying the caller's
// effective_permissions.
func TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions(t *testing.T) {
f, token := newEnabledPolicyFixture(t)
f.Admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
return []models.ListBucketsResponseItem{
{ID: "id-a", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-a"}},
{ID: "id-b", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-b"}},
{ID: "id-x", Created: time.Unix(0, 0), GlobalAliases: []string{"denied-x"}},
}, nil
}
f.Admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: alias, Objects: 0, Bytes: 0}, nil
}
req := httptest.NewRequest("GET", "/api/v1/buckets", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.BucketListResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Data.Count != 2 {
t.Fatalf("count = %d, want 2 (denied-x filtered out): %+v", body.Data.Count, body.Data.Buckets)
}
seen := map[string]bool{}
for _, b := range body.Data.Buckets {
seen[b.Name] = true
if !strings.HasPrefix(b.Name, "allowed-") {
t.Errorf("bucket %q returned, want only allowed-* buckets", b.Name)
}
if len(b.EffectivePermissions) == 0 {
t.Errorf("bucket %q: effective_permissions missing", b.Name)
}
}
if !seen["allowed-a"] || !seen["allowed-b"] {
t.Errorf("missing expected buckets, got: %+v", body.Data.Buckets)
}
if seen["denied-x"] {
t.Error("denied-x should not be visible to a team without bucket.list on that prefix")
}
}
// TestPreviewTokenGrantsObjectGET exercises the full production chain: group
// cascade AuthMiddleware accepts the token, ResolveSubject finds no user,
// and Require allows via the preview claims instead of a subject.
func TestPreviewTokenGrantsObjectGET(t *testing.T) {
f, _ := newEnabledPolicyFixture(t)
// The full-object body echoes the key the handler was actually asked to
// serve (the decoded c.Params("*")). Asserting the streamed body equals
// the exact key the token was minted for makes any future divergence
// between the validated key and the served key fail loudly here rather
// than hide behind a constant body.
const mintedKey = "media/clip.mp4"
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader(key)), &models.ObjectInfo{Key: key, Size: int64(len(key)), ContentType: "video/mp4", LastModified: time.Now()}, nil
}
f.S3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 5, ContentType: "video/mp4", LastModified: time.Now()}, nil
}
f.S3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("ell")), nil
}
token, _, err := f.Auth.MintPreviewToken("allowed-data", mintedKey, time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
tokenized := "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4?pt=" + url.QueryEscape(token)
// No Authorization header anywhere in this test.
do := func(method, path, rangeHeader string) *http.Response {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", method, path, err)
}
return resp
}
resp := do("GET", tokenized, "")
if resp.StatusCode != 200 {
t.Fatalf("tokenized GET: status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != mintedKey {
t.Errorf("served key = %q, want %q (validated key must equal served key)", body, mintedKey)
}
// Seeking works through the same token.
resp = do("GET", tokenized, "bytes=1-3")
if resp.StatusCode != 206 {
t.Errorf("tokenized ranged GET: status = %d, want 206", resp.StatusCode)
}
// The token never opens the JSON subroutes or other objects.
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4%2Fmetadata?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
t.Errorf("metadata with token: status = %d, want 401", resp.StatusCode)
}
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/other.mp4?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
t.Errorf("other object with token: status = %d, want 401", resp.StatusCode)
}
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4", ""); resp.StatusCode != 401 {
t.Errorf("no token, no auth: status = %d, want 401", resp.StatusCode)
}
}