mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-03 02:32:35 +01:00
* security: share the filer admin token check CheckGrpcAdminPeer verifies an admin Bearer token inline. Move the check into IsFilerAdminToken so an HTTP endpoint can accept the same token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: recognize the cluster's own components by a signed token A request whose X-Seaweedfs-Cluster-Token header carries an admin token signed with jwt.filer_signing.key comes from a component that holds the cluster key: the vector engine or the Lance maintenance worker. Mark such a request as trusted. It still authenticates as an identity, which stays the principal for vended credentials. A dedicated header leaves the Authorization header to Lance OAuth clients. A gateway with no key trusts no one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: vend write credentials to a vector bucket's indexes only to trusted callers An index in a vector bucket's vector namespace is also a Lance table, so a caller granted s3tables:UpdateTable could describe it, or declare it again, and get credentials that write the index data without passing the vector bucket policy. In that namespace both now vend read-only credentials unless the caller is trusted, and a declare from anyone else no longer writes the dataset markers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance worker: prove the cluster key to the Lance namespace The gateway now vends write credentials to a vector bucket's indexes only to callers that send a token signed with jwt.filer_signing.key. The worker already takes that key for the admin stream; its namespace client now mints a token from it for every catalog request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: describe the index the gateway created and prove the cluster key CreateIndex declared a table the gateway had just created, which only worked because declare is idempotent. Describe it instead. The Lance namespace vends write credentials to a vector bucket's indexes only to callers that send a token signed with jwt.filer_signing.key, so take the key with --filer-signing-key, the same flag the Lance worker has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance worker: keep the cluster key out of Debug output Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: answer a declare of an existing table with its own location CreateTable succeeds for a name that already exists without looking at the location requested, and declare kept using that location for the dataset markers and the vended credentials. Declaring an existing table with another table's location, such as a vector index, got credentials to that table's data. Use the location the catalog holds, which CreateTable now also reports for a new table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: sign cluster tokens with a key derived for the catalog A cluster token was a filer admin token signed with jwt.filer_signing.key and, by default, never expired. Sent to the Lance namespace on every request, often over plain HTTP, a captured one would also authorize the filer's IAM service. Sign it with a key derived from the cluster key for this one use, which the admin services do not accept, and mint it with a five minute lifetime. weed-vector drops its token lifetime flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: follow a reloaded jwt.filer_signing.key The Lance namespace copied the cluster key at startup. A SIGHUP reload refreshes the gateway's filer guard but not that copy, so after a key rotation the restarted vector engine and worker were no longer trusted. Read the key from the guard on every check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * iceberg: keep a vector bucket's indexes read-only An index in a vector bucket is also a catalog table the Iceberg REST catalog loads, and LoadTable vended credentials that could write it to any caller granted s3tables:UpdateTable. A commit staged its metadata file in the index directory on the same grant. Apply the vector namespace rule to both, after the caller's own UpdateTable check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * iceberg: confine a created table's location to its namespace before writing Stage-create skips the registration that confines a location to the table's namespace, yet vends write credentials for that location. A caller allowed to create a table in any namespace of a bucket could name another namespace's prefix, a vector index included, and get write credentials to it. The non-staged create also wrote its first metadata file there before the registration refused the location. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: a declare that finds an existing table answers like describe Creating a name that already exists is idempotent, so a caller allowed only to create tables could declare someone else's table and get write credentials to it, and clear its deregistered marker. Describe would have vended that caller read-only credentials. A declare now looks the name up first; when it only finds the table, writing needs UpdateTable, as it does for describe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
461 lines
19 KiB
Go
461 lines
19 KiB
Go
package lance
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
|
)
|
|
|
|
// openAuthenticator stands in for a gateway with no IAM configured, which is the
|
|
// mode the namespace falls open in.
|
|
type openAuthenticator struct{}
|
|
|
|
func (openAuthenticator) AuthenticateRequest(*http.Request) (string, interface{}, s3err.ErrorCode) {
|
|
return s3tables.DefaultAccountID, nil, s3err.ErrNone
|
|
}
|
|
|
|
func (openAuthenticator) DefaultAllow() bool { return true }
|
|
|
|
type testHarness struct {
|
|
router *mux.Router
|
|
filer *s3tablestest.MemFiler
|
|
admin *s3tables.Manager
|
|
server *Server
|
|
}
|
|
|
|
func newTestHarness(t *testing.T) *testHarness {
|
|
t.Helper()
|
|
filer := s3tablestest.Start(t)
|
|
server := NewServer(s3tables.NewManagerClient(filer.Client), openAuthenticator{})
|
|
server.SetS3Endpoint("http://127.0.0.1:8333")
|
|
server.SetS3Region(s3tables.DefaultRegion)
|
|
router := mux.NewRouter().SkipClean(true)
|
|
server.RegisterRoutes(router)
|
|
|
|
// Table buckets are created by an operator, not by a namespace client, so
|
|
// the harness seeds them the way the shell and admin console would.
|
|
admin := s3tables.NewManager()
|
|
admin.SetTrusted(true)
|
|
return &testHarness{router: router, filer: filer, admin: admin, server: server}
|
|
}
|
|
|
|
// createBucket seeds a table bucket the Lance namespace can then be pointed at.
|
|
// An empty format strips the declaration afterwards, which is the only way to
|
|
// get the shape a bucket made before formats existed has: one that still
|
|
// accepts either format.
|
|
func (h *testHarness) createBucket(t *testing.T, name, format string) {
|
|
t.Helper()
|
|
var resp s3tables.CreateTableBucketResponse
|
|
declared := format
|
|
if declared == "" {
|
|
declared = s3tables.FormatIceberg
|
|
}
|
|
err := h.admin.Execute(t.Context(), s3tables.NewManagerClient(h.filer.Client), "CreateTableBucket",
|
|
&s3tables.CreateTableBucketRequest{Name: name, Format: declared}, &resp, "")
|
|
if err != nil {
|
|
t.Fatalf("create table bucket %s: %v", name, err)
|
|
}
|
|
if format == "" {
|
|
h.undeclareBucket(t, name)
|
|
}
|
|
}
|
|
|
|
// undeclareBucket removes a bucket's format from its metadata, ageing it back to
|
|
// what the filer holds for a bucket created before the field existed.
|
|
func (h *testHarness) undeclareBucket(t *testing.T, name string) {
|
|
t.Helper()
|
|
entry := h.filer.Get(s3tables.TablesPath, name)
|
|
if entry == nil {
|
|
t.Fatalf("table bucket %s is not in the filer", name)
|
|
}
|
|
var metadata map[string]any
|
|
if err := json.Unmarshal(entry.Extended[s3tables.ExtendedKeyMetadata], &metadata); err != nil {
|
|
t.Fatalf("read bucket metadata: %v", err)
|
|
}
|
|
delete(metadata, "format")
|
|
updated, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
t.Fatalf("write bucket metadata: %v", err)
|
|
}
|
|
extended := map[string][]byte{}
|
|
for key, value := range entry.Extended {
|
|
extended[key] = value
|
|
}
|
|
extended[s3tables.ExtendedKeyMetadata] = updated
|
|
h.filer.Put(s3tables.TablesPath, name, extended)
|
|
}
|
|
|
|
func (h *testHarness) bucketARN(t *testing.T, name string) string {
|
|
t.Helper()
|
|
arn, err := s3tables.BuildBucketARN(s3tables.DefaultRegion, s3tables.DefaultAccountID, name)
|
|
if err != nil {
|
|
t.Fatalf("build arn: %v", err)
|
|
}
|
|
return arn
|
|
}
|
|
|
|
func (h *testHarness) do(t *testing.T, method, target, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader *strings.Reader
|
|
if body == "" {
|
|
reader = strings.NewReader("")
|
|
} else {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
req := httptest.NewRequest(method, target, reader)
|
|
recorder := httptest.NewRecorder()
|
|
h.router.ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
|
|
func (h *testHarness) mustDo(t *testing.T, method, target, body string, want int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
recorder := h.do(t, method, target, body)
|
|
if recorder.Code != want {
|
|
t.Fatalf("%s %s = %d (%s), want %d", method, target, recorder.Code, recorder.Body.String(), want)
|
|
}
|
|
return recorder
|
|
}
|
|
|
|
func decode[T any](t *testing.T, recorder *httptest.ResponseRecorder) T {
|
|
t.Helper()
|
|
var out T
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode %s: %v", recorder.Body.String(), err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The lifecycle a Lance client drives: create the namespace, declare the table
|
|
// before any data exists, resolve it to a location, then deregister and bring it
|
|
// back by registering the same location.
|
|
func TestTableLifecycle(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
|
|
declared := decode[DeclareTableResponse](t,
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", `{}`, http.StatusOK))
|
|
if declared.Location != "s3://analytics/sales/orders" {
|
|
t.Fatalf("declared location = %q", declared.Location)
|
|
}
|
|
// The endpoint is plaintext, so a client that does not get allow_http fails
|
|
// with what looks like a credential error.
|
|
if declared.StorageOptions["aws_endpoint"] != "http://127.0.0.1:8333" ||
|
|
declared.StorageOptions["allow_http"] != "true" {
|
|
t.Fatalf("storage options = %v", declared.StorageOptions)
|
|
}
|
|
|
|
described := decode[DescribeTableResponse](t,
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/describe?check_declared=true", `{}`, http.StatusOK))
|
|
if described.Location != declared.Location {
|
|
t.Fatalf("described location = %q, want %q", described.Location, declared.Location)
|
|
}
|
|
if described.IsOnlyDeclared == nil || !*described.IsOnlyDeclared {
|
|
t.Fatalf("is_only_declared = %v, want true for a table with no data", described.IsOnlyDeclared)
|
|
}
|
|
|
|
listed := decode[ListTablesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK))
|
|
if len(listed.Tables) != 1 || listed.Tables[0] != "analytics$sales$orders" {
|
|
t.Fatalf("listed tables = %v, want the full identifier", listed.Tables)
|
|
}
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusOK)
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/deregister", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusNotFound)
|
|
afterDrop := decode[ListTablesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK))
|
|
if len(afterDrop.Tables) != 0 {
|
|
t.Fatalf("deregistered table still listed: %v", afterDrop.Tables)
|
|
}
|
|
// Deregistering preserves the data. The catalog entry is the dataset
|
|
// directory, so dropping it would take the dataset with it.
|
|
if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") == nil {
|
|
t.Fatal("deregister deleted the dataset directory")
|
|
}
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register",
|
|
`{"location":"s3://analytics/sales/orders"}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusOK)
|
|
|
|
// Dropping is the operation that does remove the data.
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/drop", `{}`, http.StatusOK)
|
|
if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") != nil {
|
|
t.Fatal("drop left the dataset directory behind")
|
|
}
|
|
}
|
|
|
|
// Repointing a registered name at another dataset must not take the dataset it
|
|
// used to name with it.
|
|
func TestRegisterOverwriteKeepsTheOldDataset(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$archive/declare", `{}`, http.StatusOK)
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register",
|
|
`{"location":"s3://analytics/sales/archive","mode":"Overwrite"}`, http.StatusOK)
|
|
|
|
described := decode[DescribeTableResponse](t,
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/describe", `{}`, http.StatusOK))
|
|
if described.Location != "s3://analytics/sales/archive" {
|
|
t.Fatalf("location after repointing = %q", described.Location)
|
|
}
|
|
if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") == nil {
|
|
t.Fatal("repointing deleted the dataset the name used to hold")
|
|
}
|
|
}
|
|
|
|
// A required list field answers empty rather than null, which a generated
|
|
// client may decode differently.
|
|
func TestListAllTablesIsNeverNull(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
|
|
body := h.mustDo(t, http.MethodGet, "/v1/table", "", http.StatusOK).Body.String()
|
|
if strings.Contains(body, `"tables":null`) {
|
|
t.Fatalf("ListAllTables returned null for a required field: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestNamespaceListing(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$finance/create", `{}`, http.StatusOK)
|
|
|
|
roots := decode[ListNamespacesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/$/list", "", http.StatusOK))
|
|
if len(roots.Namespaces) != 1 || roots.Namespaces[0] != "analytics" {
|
|
t.Fatalf("root listing = %v, want the table buckets", roots.Namespaces)
|
|
}
|
|
|
|
children := decode[ListNamespacesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics/list", "", http.StatusOK))
|
|
if len(children.Namespaces) != 2 {
|
|
t.Fatalf("bucket listing = %v, want two namespaces", children.Namespaces)
|
|
}
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/exists", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/exists", `{}`, http.StatusNotFound)
|
|
|
|
// Fail mode reports a missing namespace as 400 on this operation, which is
|
|
// what the spec asks for; Skip reports success.
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/drop", `{}`, http.StatusBadRequest)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/drop", `{"mode":"Skip"}`, http.StatusNoContent)
|
|
}
|
|
|
|
// Storage flattens a namespace's parts, so creating a$b without a would leave
|
|
// an intermediate that listing derives from the name and describe denies
|
|
// exists. The spec asks for NamespaceNotFound, which keeps them consistent.
|
|
func TestCreateNamespaceRequiresItsParent(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$missing$child/create", `{}`, http.StatusNotFound)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeNamespaceNotFound {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeNamespaceNotFound)
|
|
}
|
|
|
|
// With the parent in place the child is fine, and both are then listed and
|
|
// describable.
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$parent/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$parent$child/create", `{}`, http.StatusOK)
|
|
|
|
listed := decode[ListNamespacesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics/list", "", http.StatusOK))
|
|
for _, name := range listed.Namespaces {
|
|
path := "/v1/namespace/analytics$" + name
|
|
h.mustDo(t, http.MethodPost, path+"/exists", `{}`, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
func TestCreateNamespaceModes(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusConflict)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{"mode":"ExistOk"}`, http.StatusOK)
|
|
// snake_case is the other spelling the spec accepts.
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{"mode":"exist_ok"}`, http.StatusOK)
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/$/create", `{}`, http.StatusBadRequest)
|
|
}
|
|
|
|
// A Lance client must never resolve an Iceberg table's location, or it would
|
|
// write a dataset over a table another engine owns.
|
|
func TestIcebergTablesAreInvisible(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
// Undeclared, because a bucket that declares one format cannot hold the
|
|
// other - and mixing is exactly what this test needs to prove is hidden.
|
|
h.createBucket(t, "analytics", "")
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$vectors/declare", `{}`, http.StatusOK)
|
|
|
|
manager := h.admin
|
|
var created s3tables.CreateTableResponse
|
|
err := manager.Execute(t.Context(), s3tables.NewManagerClient(h.filer.Client), "CreateTable", &s3tables.CreateTableRequest{
|
|
TableBucketARN: h.bucketARN(t, "analytics"),
|
|
Namespace: []string{"sales"},
|
|
Name: "ledger",
|
|
Format: s3tables.FormatIceberg,
|
|
MetadataLocation: "s3://analytics/sales/ledger/metadata/v1.metadata.json",
|
|
}, &created, "")
|
|
if err != nil {
|
|
t.Fatalf("create iceberg table: %v", err)
|
|
}
|
|
|
|
listed := decode[ListTablesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK))
|
|
if len(listed.Tables) != 1 || listed.Tables[0] != "analytics$sales$vectors" {
|
|
t.Fatalf("listing = %v, want only the lance table", listed.Tables)
|
|
}
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/describe", `{}`, http.StatusNotFound)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/exists", `{}`, http.StatusNotFound)
|
|
|
|
// Declaring over the Iceberg table must not quietly succeed and hand the
|
|
// Lance client a directory another format owns.
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/declare", `{}`, http.StatusConflict)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeTableAlreadyExists {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeTableAlreadyExists)
|
|
}
|
|
}
|
|
|
|
// The route and the body naming different objects is a bad request, not a silent
|
|
// preference for one of them.
|
|
func TestRouteAndBodyMustAgree(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare",
|
|
`{"id":["analytics","sales","other"]}`, http.StatusBadRequest)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput)
|
|
}
|
|
}
|
|
|
|
// A client-supplied location must resolve inside its own bucket. Without this,
|
|
// a "../"-laden or cross-bucket location escapes when the marker path is joined
|
|
// under /buckets, letting the request reach another tenant's namespace, or
|
|
// outside /buckets entirely, and auto-create the parent directories on the way.
|
|
func TestLocationOutsideBucketIsRejected(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
location string
|
|
}{
|
|
{"another bucket", "s3://victim/secret"},
|
|
{"traversal into another bucket", "s3://analytics/../victim/secret"},
|
|
{"traversal above the buckets root", "s3://analytics/../../etc/cron.d"},
|
|
{"not an s3 uri", "../../etc/cron.d"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := s3tables.TableDataDirFromMetadataLocation(tc.location)
|
|
body := `{"location":"` + tc.location + `"}`
|
|
|
|
// declare writes .lance-reserved at the location: rejection must leave
|
|
// nothing behind at the escaped directory.
|
|
t.Run("declare", func(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", body, http.StatusBadRequest)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput)
|
|
}
|
|
if dir != "" && h.filer.Get(dir, reservedMarker) != nil {
|
|
t.Fatalf("declare wrote %s into %s despite rejection", reservedMarker, dir)
|
|
}
|
|
})
|
|
|
|
// register removes .lance-deregistered at the location: rejection must
|
|
// leave a victim's marker in place rather than un-hiding their table.
|
|
t.Run("register", func(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
if dir != "" {
|
|
h.filer.PutFile(dir, deregisteredMarker, time.Unix(1, 0))
|
|
}
|
|
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register", body, http.StatusBadRequest)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput)
|
|
}
|
|
if dir != "" && h.filer.Get(dir, deregisteredMarker) == nil {
|
|
t.Fatalf("register removed %s from %s despite rejection", deregisteredMarker, dir)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// The data plane needs Lance format support that does not exist in Go, so it
|
|
// answers with the spec's own Unsupported code rather than a bare 404.
|
|
func TestDataPlaneIsUnsupported(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/query", `{}`, http.StatusNotImplemented)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeUnsupported {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeUnsupported)
|
|
}
|
|
}
|
|
|
|
// createOnlyAuthenticator signs in an identity that may create and read tables
|
|
// but not update them.
|
|
type createOnlyAuthenticator struct{}
|
|
|
|
func (createOnlyAuthenticator) AuthenticateRequest(*http.Request) (string, interface{}, s3err.ErrorCode) {
|
|
return "creator", &struct {
|
|
Name string
|
|
Actions []string
|
|
}{Name: "creator", Actions: []string{"s3tables:CreateTable", "s3tables:GetTable"}}, s3err.ErrNone
|
|
}
|
|
|
|
func (createOnlyAuthenticator) DefaultAllow() bool { return false }
|
|
|
|
// The right to create a name is the write evidence for a table the declare
|
|
// creates, but not for one it only finds.
|
|
func TestDeclareVendsWriteOnlyForATableItCreates(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "lnc", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/lnc$ns/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/lnc$ns$theirs/declare", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/lnc$ns$theirs/deregister", `{}`, http.StatusOK)
|
|
|
|
server := NewServer(s3tables.NewManagerClient(h.filer.Client), createOnlyAuthenticator{})
|
|
server.SetCredentialVendor(recordingVendor{})
|
|
router := mux.NewRouter().SkipClean(true)
|
|
server.RegisterRoutes(router)
|
|
creator := &testHarness{router: router, filer: h.filer, admin: h.admin, server: server}
|
|
|
|
for _, tc := range []struct{ name, want string }{
|
|
{"theirs", "read-only"},
|
|
{"mine", "read-write"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := creator.vendedKey(t, "/v1/table/lnc$ns$"+tc.name+"/declare", ""); got != tc.want {
|
|
t.Errorf("vended %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
if h.filer.Get("/buckets/lnc/ns/theirs", deregisteredMarker) == nil {
|
|
t.Error("a declare that may not write the table brought it back")
|
|
}
|
|
}
|