mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-31 17:28:14 +00:00
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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user