mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
* iceberg: withhold the S3 endpoint from credential-vending clients A client that sends X-Iceberg-Access-Delegation: vended-credentials builds its storage credential out of the LoadTable config and drops the one it was configured with. We vend no credentials, so the endpoint we advertised left DuckDB signing nothing: every metadata and data file came back 403, and its attempt to refresh the empty credential 404ed on stage-created tables. Answer those clients with no config at all so they keep their own credentials. Clients that do not ask for delegation still get the endpoint. * iceberg: mark load responses as varying on the delegation header The FileIO config in a table or view load response now depends on whether the client asked for vended credentials, so a cache between us and the client must key on that header rather than on the URL alone. * test: cover the DuckDB vended-credentials access pattern Runs weed mini with -s3.externalUrl, which is what makes the catalog advertise an endpoint at all, and checks both halves: a plain LoadTable still gets the endpoint, while one asking for vended credentials never gets an endpoint without the credentials to sign with. The DuckDB round trip creates a table from a query and reads it back, which is the flow that failed with 403 on every data file.
473 lines
14 KiB
Go
473 lines
14 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/test/testutil"
|
|
)
|
|
|
|
// oauthTestEnv holds a weed mini instance started with IAM credentials
|
|
// so that the OAuth token endpoint is functional.
|
|
type oauthTestEnv struct {
|
|
seaweedDir string
|
|
weedBinary string
|
|
dataDir string
|
|
bindIP string
|
|
s3Port int
|
|
s3GrpcPort int
|
|
icebergPort int
|
|
masterPort int
|
|
masterGrpcPort int
|
|
filerPort int
|
|
filerGrpcPort int
|
|
volumePort int
|
|
volumeGrpcPort int
|
|
webdavPort int
|
|
weedProcess *exec.Cmd
|
|
weedCancel context.CancelFunc
|
|
accessKey string
|
|
secretKey string
|
|
// s3ExternalURL, when set before start, is the S3 endpoint the catalog
|
|
// advertises to clients in LoadTable FileIO config.
|
|
s3ExternalURL string
|
|
}
|
|
|
|
func newOAuthTestEnv(t *testing.T) *oauthTestEnv {
|
|
t.Helper()
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("get working directory: %v", err)
|
|
}
|
|
|
|
seaweedDir := wd
|
|
for i := 0; i < 6; i++ {
|
|
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
|
|
break
|
|
}
|
|
seaweedDir = filepath.Dir(seaweedDir)
|
|
}
|
|
|
|
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
|
|
if info, err := os.Stat(weedBinary); err != nil || info.IsDir() {
|
|
weedBinary = "weed"
|
|
if _, err := exec.LookPath(weedBinary); err != nil {
|
|
t.Skip("weed binary not found, skipping integration test")
|
|
}
|
|
}
|
|
|
|
dataDir, err := os.MkdirTemp("", "seaweed-oauth-test-*")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
|
|
bindIP := testutil.FindBindIP()
|
|
ports := testutil.MustAllocatePorts(t, 10)
|
|
|
|
return &oauthTestEnv{
|
|
seaweedDir: seaweedDir,
|
|
weedBinary: weedBinary,
|
|
dataDir: dataDir,
|
|
bindIP: bindIP,
|
|
masterPort: ports[0],
|
|
masterGrpcPort: ports[1],
|
|
volumePort: ports[2],
|
|
volumeGrpcPort: ports[3],
|
|
filerPort: ports[4],
|
|
filerGrpcPort: ports[5],
|
|
s3Port: ports[6],
|
|
s3GrpcPort: ports[7],
|
|
icebergPort: ports[8],
|
|
webdavPort: ports[9],
|
|
accessKey: "AKIAIOSFODNN7EXAMPLE",
|
|
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
}
|
|
}
|
|
|
|
func (env *oauthTestEnv) start(t *testing.T) {
|
|
t.Helper()
|
|
|
|
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
|
|
if err != nil {
|
|
t.Fatalf("write IAM config: %v", err)
|
|
}
|
|
|
|
securityToml := filepath.Join(env.dataDir, "security.toml")
|
|
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
|
|
t.Fatalf("write security.toml: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
env.weedCancel = cancel
|
|
|
|
args := []string{"mini",
|
|
"-master.port", fmt.Sprintf("%d", env.masterPort),
|
|
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
|
|
"-volume.port", fmt.Sprintf("%d", env.volumePort),
|
|
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
|
|
"-filer.port", fmt.Sprintf("%d", env.filerPort),
|
|
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
|
|
"-s3.port", fmt.Sprintf("%d", env.s3Port),
|
|
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
|
|
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
|
|
"-webdav.port", fmt.Sprintf("%d", env.webdavPort),
|
|
"-s3.config", iamConfigPath,
|
|
"-ip", env.bindIP,
|
|
"-ip.bind", "0.0.0.0",
|
|
"-dir", env.dataDir,
|
|
}
|
|
if env.s3ExternalURL != "" {
|
|
args = append(args, "-s3.externalUrl", env.s3ExternalURL)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, env.weedBinary, args...)
|
|
cmd.Dir = env.dataDir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Env = append(os.Environ(),
|
|
"AWS_ACCESS_KEY_ID="+env.accessKey,
|
|
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
|
|
)
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
t.Fatalf("start weed mini: %v", err)
|
|
}
|
|
env.weedProcess = cmd
|
|
|
|
icebergURL := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
|
|
if !testutil.WaitForService(icebergURL, 30*time.Second) {
|
|
cancel()
|
|
cmd.Wait()
|
|
t.Fatalf("Iceberg REST API did not become ready at %s", icebergURL)
|
|
}
|
|
}
|
|
|
|
func (env *oauthTestEnv) cleanup(t *testing.T) {
|
|
t.Helper()
|
|
if env.weedCancel != nil {
|
|
env.weedCancel()
|
|
}
|
|
if env.weedProcess != nil {
|
|
env.weedProcess.Wait()
|
|
}
|
|
if env.dataDir != "" {
|
|
os.RemoveAll(env.dataDir)
|
|
}
|
|
}
|
|
|
|
func (env *oauthTestEnv) icebergURL() string {
|
|
return fmt.Sprintf("http://%s:%d", env.bindIP, env.icebergPort)
|
|
}
|
|
|
|
// TestOAuthTokenEndpoint tests the /v1/oauth/tokens endpoint directly via HTTP.
|
|
func TestOAuthTokenEndpoint(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
env := newOAuthTestEnv(t)
|
|
defer env.cleanup(t)
|
|
env.start(t)
|
|
|
|
t.Run("valid credentials", func(t *testing.T) {
|
|
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
|
|
if token == "" {
|
|
t.Fatal("expected non-empty token")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid secret", func(t *testing.T) {
|
|
resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"client_id": {env.accessKey},
|
|
"client_secret": {"wrong-secret"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("POST /v1/oauth/tokens: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("expected 401, got %d: %s", resp.StatusCode, body)
|
|
}
|
|
})
|
|
|
|
t.Run("missing grant_type", func(t *testing.T) {
|
|
resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
|
|
"client_id": {env.accessKey},
|
|
"client_secret": {env.secretKey},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("POST /v1/oauth/tokens: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("expected 400, got %d: %s", resp.StatusCode, body)
|
|
}
|
|
})
|
|
|
|
t.Run("bearer token auth on catalog endpoint", func(t *testing.T) {
|
|
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
|
|
|
|
// Point the call at a table bucket that exists, so anything but 200 is an
|
|
// auth or routing fault rather than the catalog reporting a missing bucket.
|
|
bucketName := "oauth-bearer-" + randomSuffix()
|
|
createTableBucketViaShell(t, env, bucketName)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, env.icebergURL()+"/v1/"+bucketName+"/namespaces", nil)
|
|
if err != nil {
|
|
t.Fatalf("create request: %v", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("GET /v1/%s/namespaces with Bearer: %v", bucketName, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Bearer auth failed unexpectedly: status=%d body=%s", resp.StatusCode, body)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDuckDBOAuthIntegration tests that DuckDB can connect to the Iceberg REST
|
|
// catalog using the OAuth2 client_credentials flow (CREATE SECRET with client_id
|
|
// and client_secret). This is the scenario reported in issue #9015.
|
|
func TestDuckDBOAuthIntegration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
if !testutil.HasDocker() {
|
|
t.Skip("Docker not available, skipping DuckDB OAuth integration test")
|
|
}
|
|
|
|
env := newOAuthTestEnv(t)
|
|
defer env.cleanup(t)
|
|
env.start(t)
|
|
|
|
// Create a table bucket and namespace so DuckDB has something to query
|
|
bucketName := "duckdb-oauth-" + randomSuffix()
|
|
createTableBucketViaShell(t, env, bucketName)
|
|
|
|
// Create a namespace via the Iceberg REST API using OAuth token
|
|
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
|
|
createNamespaceWithToken(t, env, token, bucketName, "testns")
|
|
|
|
sqlContent := fmt.Sprintf(`
|
|
INSTALL iceberg;
|
|
LOAD iceberg;
|
|
|
|
CREATE SECRET iceberg_secret (
|
|
TYPE ICEBERG,
|
|
ENDPOINT 'http://host.docker.internal:%d',
|
|
CLIENT_ID '%s',
|
|
CLIENT_SECRET '%s',
|
|
SCOPE 's3://%s/'
|
|
);
|
|
|
|
CREATE SECRET s3_secret (
|
|
TYPE S3,
|
|
KEY_ID '%s',
|
|
SECRET '%s',
|
|
ENDPOINT 'host.docker.internal:%d',
|
|
URL_STYLE 'path',
|
|
USE_SSL false,
|
|
SCOPE 's3://%s/'
|
|
);
|
|
|
|
SELECT 'OAuth token obtained successfully' as status;
|
|
|
|
-- Try listing namespaces via the Iceberg catalog
|
|
SELECT * FROM iceberg_scan('iceberg_secret', ALLOW_MOVED_PATHS => TRUE) LIMIT 0;
|
|
`, env.icebergPort, env.accessKey, env.secretKey, bucketName,
|
|
env.accessKey, env.secretKey, env.s3Port, bucketName)
|
|
|
|
sqlFile := filepath.Join(env.dataDir, "duckdb_oauth_test.sql")
|
|
if err := os.WriteFile(sqlFile, []byte(sqlContent), 0644); err != nil {
|
|
t.Fatalf("write SQL file: %v", err)
|
|
}
|
|
|
|
// Run DuckDB in Docker.
|
|
// We use a simple test: get a token, create a secret, and verify the
|
|
// Iceberg extension can communicate with the catalog.
|
|
// The simpler fallback test just verifies CREATE SECRET succeeds (no 404).
|
|
fallbackSQL := fmt.Sprintf(`
|
|
INSTALL iceberg;
|
|
LOAD iceberg;
|
|
|
|
CREATE SECRET (
|
|
TYPE ICEBERG,
|
|
ENDPOINT 'http://host.docker.internal:%d',
|
|
CLIENT_ID '%s',
|
|
CLIENT_SECRET '%s'
|
|
);
|
|
|
|
SELECT 'DuckDB Iceberg OAuth secret created successfully' as result;
|
|
`, env.icebergPort, env.accessKey, env.secretKey)
|
|
|
|
fallbackFile := filepath.Join(env.dataDir, "duckdb_oauth_fallback.sql")
|
|
if err := os.WriteFile(fallbackFile, []byte(fallbackSQL), 0644); err != nil {
|
|
t.Fatalf("write fallback SQL file: %v", err)
|
|
}
|
|
|
|
// Try the full SQL script first (CREATE SECRET + catalog access).
|
|
// Fall back to the simple CREATE SECRET test if iceberg_scan isn't supported.
|
|
cmd := exec.Command("docker", "run", "--rm",
|
|
"-v", fmt.Sprintf("%s:/test", env.dataDir),
|
|
"--add-host", "host.docker.internal:host-gateway",
|
|
"--entrypoint", "duckdb",
|
|
"duckdb/duckdb:latest",
|
|
"-init", "/test/duckdb_oauth_test.sql",
|
|
"-c", "SELECT 1",
|
|
)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
t.Logf("DuckDB output:\n%s", outputStr)
|
|
|
|
if err != nil {
|
|
if strings.Contains(outputStr, "iceberg extension is not available") ||
|
|
strings.Contains(outputStr, "Failed to load") {
|
|
t.Skip("Skipping: Iceberg extension not available in DuckDB Docker image")
|
|
}
|
|
// The key check: the old error was "HTTP NotFound_404" on /v1/oauth/tokens.
|
|
// With our fix, this should no longer happen.
|
|
if strings.Contains(outputStr, "NotFound_404") && strings.Contains(outputStr, "/v1/oauth/tokens") {
|
|
t.Fatal("OAuth token endpoint returned 404 - the fix is not working")
|
|
}
|
|
// If iceberg_scan failed but CREATE SECRET worked, fall back to simpler test
|
|
if strings.Contains(outputStr, "OAuth token obtained successfully") {
|
|
t.Logf("Full SQL had partial success (token obtained), iceberg_scan may not be supported. Continuing.")
|
|
} else {
|
|
// Try the fallback script that only tests CREATE SECRET
|
|
t.Logf("Full SQL failed, trying fallback CREATE SECRET test...")
|
|
fallbackCmd := exec.Command("docker", "run", "--rm",
|
|
"-v", fmt.Sprintf("%s:/test", env.dataDir),
|
|
"--add-host", "host.docker.internal:host-gateway",
|
|
"--entrypoint", "duckdb",
|
|
"duckdb/duckdb:latest",
|
|
"-init", "/test/duckdb_oauth_fallback.sql",
|
|
"-c", "SELECT 1",
|
|
)
|
|
fallbackOutput, fallbackErr := fallbackCmd.CombinedOutput()
|
|
fallbackStr := string(fallbackOutput)
|
|
t.Logf("DuckDB fallback output:\n%s", fallbackStr)
|
|
|
|
if fallbackErr != nil {
|
|
if strings.Contains(fallbackStr, "NotFound_404") && strings.Contains(fallbackStr, "/v1/oauth/tokens") {
|
|
t.Fatal("OAuth token endpoint returned 404 - the fix is not working")
|
|
}
|
|
t.Fatalf("DuckDB fallback also failed: %v\nOutput: %s", fallbackErr, fallbackStr)
|
|
}
|
|
if !strings.Contains(fallbackStr, "DuckDB Iceberg OAuth secret created successfully") {
|
|
t.Errorf("expected success message in fallback output, got:\n%s", fallbackStr)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if strings.Contains(outputStr, "OAuth token obtained successfully") {
|
|
t.Logf("DuckDB OAuth CREATE SECRET succeeded")
|
|
} else {
|
|
t.Errorf("expected OAuth success message in output, got:\n%s", outputStr)
|
|
}
|
|
}
|
|
|
|
// requestOAuthToken obtains a bearer token from the OAuth endpoint.
|
|
func requestOAuthToken(t *testing.T, env *oauthTestEnv, accessKey, secretKey string) string {
|
|
t.Helper()
|
|
|
|
resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"client_id": {accessKey},
|
|
"client_secret": {secretKey},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("POST /v1/oauth/tokens: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("OAuth token request failed: status=%d body=%s", resp.StatusCode, body)
|
|
}
|
|
|
|
var tokenResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
t.Fatalf("decode token response: %v", err)
|
|
}
|
|
|
|
if tokenResp.AccessToken == "" {
|
|
t.Fatal("got empty access_token")
|
|
}
|
|
if tokenResp.TokenType != "bearer" {
|
|
t.Errorf("expected token_type=bearer, got %s", tokenResp.TokenType)
|
|
}
|
|
return tokenResp.AccessToken
|
|
}
|
|
|
|
// createTableBucketViaShell creates a table bucket using weed shell,
|
|
// which bypasses S3 auth. This is the same approach used by the Trino tests.
|
|
func createTableBucketViaShell(t *testing.T, env *oauthTestEnv, bucketName string) {
|
|
t.Helper()
|
|
|
|
cmd := exec.Command(env.weedBinary, "shell",
|
|
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
|
|
)
|
|
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, output)
|
|
}
|
|
t.Logf("Created table bucket %s", bucketName)
|
|
}
|
|
|
|
// createNamespaceWithToken creates a namespace using a Bearer token.
|
|
func createNamespaceWithToken(t *testing.T, env *oauthTestEnv, token, bucketName, namespace string) {
|
|
t.Helper()
|
|
|
|
path := fmt.Sprintf("/v1/%s/namespaces", bucketName)
|
|
body := fmt.Sprintf(`{"namespace":["%s"]}`, namespace)
|
|
req, err := http.NewRequest(http.MethodPost, env.icebergURL()+path, strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("create request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("create namespace: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("create namespace failed: status=%d body=%s", resp.StatusCode, respBody)
|
|
}
|
|
t.Logf("Created namespace %s in bucket %s", namespace, bucketName)
|
|
}
|