mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-30 17:00:48 +01:00
* s3vectors: translate vector bucket policies to the catalog form The document is stored verbatim on the shared table bucket and evaluated against s3tables actions and ARNs, so a policy written for the vector API was a dead rule. Actions now map to the catalog operation the request authorizes against, and an arn:aws:s3vectors: resource becomes the bucket/.../table/vector/... ARN the index's catalog entry answers to. * s3tables: expose PrincipalFor and PolicyContext for façade evaluation A façade authorizing against its own action namespace needs the same identity resolution and evaluation context the catalog uses internally. * s3vectors: keep bucket policies in vector form and evaluate them in the gateway The earlier approach translated the document to catalog actions and ARNs, which lost the caller's original policy for GetVectorBucketPolicy and could not cover data-plane calls that never touch the catalog. The document now stays in s3vectors form under a dedicated bucket attribute and every handler evaluates it through authorizeVector against the action and ARN the request names; a dedicated key also keeps a vector grant from ever authorizing a catalog call. * s3vectors: close policy-mutation races and keep vector grants visible - Authorize Put/DeleteVectorBucketPolicy inside the policy slot's compare-and-swap, so a grant revoked between reads cannot authorize the pending write. - Assert the policy attributes stay absent in the update precondition, so a concurrent policy or tag write trips the retry instead of being silently reverted. - Migrate a raw vector document out of the shared policy slot on replace or delete, keeping its catalog rules. - Pass the unresolved identity to PolicyContext so open-by-default deployments still work. - Filter ListVectorBuckets on the vector policy itself, so buckets a caller was granted under s3vectors actions stay listed. * s3vectors: reuse SnapshotExtended for the vector-policy CAS precondition * s3vectors: skip buckets that vanish mid-list instead of failing the page * s3vectors: reject non-vector bucket formats inside authorizeVector * s3vectors: check bucket format after authorization in authorizeVector An unauthorized caller probing an ICEBERG bucket got NoSuchBucket,\nleaking that the bucket exists. The format check now runs after the\npermission check so denied callers always see AccessDenied. * s3vectors: cover legacy shared-slot policy removal on delete * s3tables: resolve the forwarded identity name like getAccountID in PrincipalFor A synthesized manager request resolves its caller through the identity- name branch of getAccountID, which normalizes the name (ARNs collapse to their trailing segment, whitespace trims) before falling back to the handler's account ID. PrincipalFor returned the raw name, so ARN-shaped identities never matched their own grants. * s3tables: assert the vector policy key in update preconditions s3vectors-policy is stored on table bucket entries but was missing from s3tablesExtendedKeys, so a catalog update that read the entry before the first vector policy was saved could overwrite it without tripping the compare-and-swap. Asserting the key makes such a race fail instead of silently erasing the policy. * s3vectors: reject rules the evaluator cannot honor in vector bucket policies The shared Statement shape has no negated fields, the vector evaluation supplies no condition keys, and a document that fails the typed decode is discarded wholesale at evaluation — including denies the writer believed active. Reject NotPrincipal, Condition, foreign-service actions and resource ARNs, and require the typed PolicyDocument decode. Also cover that an admin-named identity still reaches its own buckets. * s3vectors: read vector policy fields the way the evaluator decodes them Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: match ARN policy principals through caller normalization Caller principals are normalized ids, so a statement naming a full IAM ARN could never match: an ARN-scoped deny was silently dead and an ARN-scoped allow unreachable. Compare arn: specs against the caller under the same normalizePrincipalID the ownership path uses, while an ARN caller keeps verbatim wildcard matching. * s3tables: grant the admin bypass on the caller's actions, not the name A principal that only normalizes to the admin id skipped every policy deny. When the request carries an identity, the bypass now requires the identity's own admin action — the same gate getAccountID applies — while name-only transports with no action list keep it. PolicyContext takes the request so it can carry IdentityActions, and the vector context helpers route through the shared identity keys so the resolved name and actions agree. * s3tables: match ARN policy principals only for deny statements Normalization drops the ARN's account and path, so an allow matched on the trailing name could grant a same-named caller the statement never verified. Denies keep the normalized match so the rule cannot be ducked through the same collapse. * s3tables: scope service wildcards to their own service s3tables:* counted as blanket admin inside the shared permission check, so an identity holding only catalog grants administered s3vectors operations too. hasAdminActionFor now requires the wildcard to name the operation's service; the global admin markers and s3:* stay service-agnostic. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
419 lines
14 KiB
Go
419 lines
14 KiB
Go
package s3tables
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
var (
|
|
ErrAttributeNotFound = errors.New("attribute not found")
|
|
ErrConcurrentUpdate = errors.New("attribute changed concurrently")
|
|
ErrNameTaken = errors.New("catalog name is taken")
|
|
)
|
|
|
|
// extendedAttributeUpdateAttempts bounds the read-modify-write retry loop in
|
|
// updateExtendedAttribute.
|
|
const extendedAttributeUpdateAttempts = 5
|
|
|
|
// Filer operations - Common functions for interacting with the filer
|
|
|
|
// createDirectory creates a new directory at the specified path
|
|
func (h *S3TablesHandler) createDirectory(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) error {
|
|
return h.createDirectoryEntry(ctx, client, path, false, nil)
|
|
}
|
|
|
|
// createDirectoryExclusive creates a directory entry together with its
|
|
// extended attributes in one CreateEntry that fails with
|
|
// filer_pb.ErrEntryAlreadyExists when an entry is already there: nothing is
|
|
// replaced and no reader sees the directory without its attributes.
|
|
func (h *S3TablesHandler) createDirectoryExclusive(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, extended map[string][]byte) error {
|
|
return h.createDirectoryEntry(ctx, client, path, true, extended)
|
|
}
|
|
|
|
func (h *S3TablesHandler) createDirectoryEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, exclusive bool, extended map[string][]byte) error {
|
|
dir, name := splitPath(path)
|
|
now := time.Now().Unix()
|
|
return filer_pb.CreateEntry(ctx, client, &filer_pb.CreateEntryRequest{
|
|
Directory: dir,
|
|
OExcl: exclusive,
|
|
Entry: &filer_pb.Entry{
|
|
Name: name,
|
|
IsDirectory: true,
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
Mtime: now,
|
|
Crtime: now,
|
|
FileMode: uint32(0755 | os.ModeDir),
|
|
},
|
|
Extended: extended,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ensureDirectory ensures a directory exists at the specified path
|
|
func (h *S3TablesHandler) ensureDirectory(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) error {
|
|
dir, name := splitPath(path)
|
|
_, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: dir,
|
|
Name: name,
|
|
})
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
return h.createDirectory(ctx, client, path)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// deleteEntryIfExists removes an entry if it exists, ignoring missing errors
|
|
func (h *S3TablesHandler) deleteEntryIfExists(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) error {
|
|
dir, name := splitPath(path)
|
|
return filer_pb.DoRemove(ctx, client, dir, name, true, false, true, false, nil)
|
|
}
|
|
|
|
// mutateEntryExtended applies mutate to an entry's extended attributes and
|
|
// writes the entry back, asserting that nothing on it changed in between.
|
|
// UpdateEntry rewrites the whole entry from the snapshot read here, so an
|
|
// unconditional write would delete an attribute a concurrent writer created.
|
|
func (h *S3TablesHandler) mutateEntryExtended(
|
|
ctx context.Context,
|
|
client filer_pb.SeaweedFilerClient,
|
|
path string,
|
|
mutate func(extended map[string][]byte) error,
|
|
) error {
|
|
dir, name := splitPath(path)
|
|
|
|
for attempt := 0; attempt < extendedAttributeUpdateAttempts; attempt++ {
|
|
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: dir,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
entry := resp.Entry
|
|
expected := snapshotExtended(entry.Extended)
|
|
if entry.Extended == nil {
|
|
entry.Extended = make(map[string][]byte)
|
|
}
|
|
if err := mutate(entry.Extended); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
|
Directory: dir,
|
|
Entry: entry,
|
|
ExpectedExtended: expected,
|
|
})
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if status.Code(err) != codes.FailedPrecondition {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("%w: %s", ErrConcurrentUpdate, path)
|
|
}
|
|
|
|
// setExtendedAttribute sets an extended attribute on an existing entry
|
|
func (h *S3TablesHandler) setExtendedAttribute(ctx context.Context, client filer_pb.SeaweedFilerClient, path, key string, data []byte) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
extended[key] = data
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// setExtendedAttributes sets multiple extended attributes on an existing entry
|
|
// in a single UpdateEntry, so callers don't leave the entry partially tagged.
|
|
func (h *S3TablesHandler) setExtendedAttributes(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, attrs map[string][]byte) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
for key, data := range attrs {
|
|
extended[key] = data
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// removeExtendedAttributes deletes the given extended attributes from an entry,
|
|
// leaving the directory and its children intact.
|
|
func (h *S3TablesHandler) removeExtendedAttributes(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, keys ...string) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
for _, key := range keys {
|
|
delete(extended, key)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// removeExtendedAttributesIf drops the given attributes only while they still
|
|
// hold the values the caller captured. A rename copies attributes to the
|
|
// destination and then clears the source; without this, a write that landed in
|
|
// between is deleted here and never reaches the new name.
|
|
func (h *S3TablesHandler) removeExtendedAttributesIf(
|
|
ctx context.Context,
|
|
client filer_pb.SeaweedFilerClient,
|
|
path string,
|
|
expected map[string][]byte,
|
|
keys ...string,
|
|
) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
for key, want := range expected {
|
|
if !bytes.Equal(extended[key], want) {
|
|
return fmt.Errorf("%w: %s changed on %s", ErrConcurrentUpdate, key, path)
|
|
}
|
|
}
|
|
for _, key := range keys {
|
|
delete(extended, key)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// claimCatalogEntry writes a new catalog entry's marker attributes in one
|
|
// compare-checked update. Every catalog marker must still be absent: a
|
|
// delete's restore or a competing create that landed since the caller's check
|
|
// is a name conflict, not a record this write may overwrite — an overwrite
|
|
// would leave the loser's tags, policy, and maintenance settings on the new
|
|
// record.
|
|
func (h *S3TablesHandler) claimCatalogEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, attrs map[string][]byte) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
for _, key := range s3tablesExtendedKeys {
|
|
if _, ok := extended[key]; ok {
|
|
return fmt.Errorf("%w: %s set on %s", ErrNameTaken, key, path)
|
|
}
|
|
}
|
|
for key, data := range attrs {
|
|
extended[key] = data
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// claimCatalogRecord reserves a catalog record for an in-flight delete or
|
|
// rename under one compare-checked write: the entry must still hold exactly
|
|
// expected. A record claimed by another kind of operation refuses the claim,
|
|
// so the loser backs out before any data or destination entry is written;
|
|
// a retry of the same kind may adopt its own earlier claim.
|
|
func (h *S3TablesHandler) claimCatalogRecord(
|
|
ctx context.Context,
|
|
client filer_pb.SeaweedFilerClient,
|
|
path string,
|
|
expected map[string][]byte,
|
|
claim string,
|
|
) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
for key, want := range expected {
|
|
if key == ExtendedKeyDeleting {
|
|
continue
|
|
}
|
|
if !bytes.Equal(extended[key], want) {
|
|
return fmt.Errorf("%w: %s changed on %s", ErrConcurrentUpdate, key, path)
|
|
}
|
|
}
|
|
if cur, ok := extended[ExtendedKeyDeleting]; ok && string(cur) != claim {
|
|
return fmt.Errorf("%w: %s held on %s", ErrConcurrentUpdate, ExtendedKeyDeleting, path)
|
|
}
|
|
extended[ExtendedKeyDeleting] = []byte(claim)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// releaseCatalogRecord lifts a claimCatalogRecord reservation while the entry
|
|
// still holds expected — which includes the claim itself — restoring the
|
|
// record so the failed operation can be retried.
|
|
func (h *S3TablesHandler) releaseCatalogRecord(
|
|
ctx context.Context,
|
|
client filer_pb.SeaweedFilerClient,
|
|
path string,
|
|
expected map[string][]byte,
|
|
) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
for key, want := range expected {
|
|
if !bytes.Equal(extended[key], want) {
|
|
return fmt.Errorf("%w: %s changed on %s", ErrConcurrentUpdate, key, path)
|
|
}
|
|
}
|
|
delete(extended, ExtendedKeyDeleting)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// updateExtendedAttribute applies mutate to the current value of key and writes
|
|
// the result back. mutate runs again on each retry, so it always sees the
|
|
// current value.
|
|
func (h *S3TablesHandler) updateExtendedAttribute(
|
|
ctx context.Context,
|
|
client filer_pb.SeaweedFilerClient,
|
|
path, key string,
|
|
mutate func(current []byte) ([]byte, error),
|
|
) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
updated, err := mutate(extended[key])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
extended[key] = updated
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// s3tablesExtendedKeys is every attribute this package stores on a bucket or
|
|
// table entry. A whole-entry write has to assert the absent ones too: a key
|
|
// missing from the precondition is a key a concurrent writer can create and
|
|
// this write will then delete.
|
|
var s3tablesExtendedKeys = []string{
|
|
ExtendedKeyTableBucket,
|
|
ExtendedKeyMetadata,
|
|
ExtendedKeyMetadataVersion,
|
|
ExtendedKeyPolicy,
|
|
ExtendedKeyTags,
|
|
ExtendedKeyMaintenance,
|
|
ExtendedKeyMaintenanceStatus,
|
|
ExtendedKeyEntryType,
|
|
ExtendedKeyDeleting,
|
|
ExtendedKeyVectorPolicy,
|
|
}
|
|
|
|
// SnapshotExtended captures an entry's attributes as an UpdateEntry
|
|
// precondition, so a whole-entry write cannot silently revert or delete an
|
|
// attribute a concurrent writer touched. Attributes absent at read time are
|
|
// asserted absent, which fails the precondition if someone creates one first.
|
|
func SnapshotExtended(extended map[string][]byte, keys ...string) map[string][]byte {
|
|
return snapshotExtended(extended, keys...)
|
|
}
|
|
|
|
func snapshotExtended(extended map[string][]byte, keys ...string) map[string][]byte {
|
|
expected := make(map[string][]byte, len(extended)+len(s3tablesExtendedKeys))
|
|
for k, v := range extended {
|
|
expected[k] = v
|
|
}
|
|
for _, k := range s3tablesExtendedKeys {
|
|
if _, ok := expected[k]; !ok {
|
|
expected[k] = nil
|
|
}
|
|
}
|
|
for _, k := range keys {
|
|
if _, ok := expected[k]; !ok {
|
|
expected[k] = nil
|
|
}
|
|
}
|
|
return expected
|
|
}
|
|
|
|
// getExtendedAttribute gets an extended attribute from an entry
|
|
func (h *S3TablesHandler) getExtendedAttribute(ctx context.Context, client filer_pb.SeaweedFilerClient, path, key string) ([]byte, error) {
|
|
dir, name := splitPath(path)
|
|
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: dir,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.Entry.Extended == nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrAttributeNotFound, key)
|
|
}
|
|
|
|
data, ok := resp.Entry.Extended[key]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrAttributeNotFound, key)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// loadNamespaceMetadata resolves a namespace to its metadata. A directory that
|
|
// carries no namespace metadata is not a namespace, so a missing attribute
|
|
// reports the same absence as a missing entry and every caller tests one
|
|
// condition instead of forgetting the second.
|
|
func (h *S3TablesHandler) loadNamespaceMetadata(ctx context.Context, filerClient FilerClient, bucketName, namespaceName string) (*namespaceMetadata, error) {
|
|
var metadata namespaceMetadata
|
|
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
data, err := h.getExtendedAttribute(ctx, client, GetNamespacePath(bucketName, namespaceName), ExtendedKeyMetadata)
|
|
if err != nil {
|
|
if errors.Is(err, ErrAttributeNotFound) {
|
|
return filer_pb.ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
return fmt.Errorf("failed to unmarshal namespace metadata: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &metadata, nil
|
|
}
|
|
|
|
// lookupEntry returns the filer entry at the given path.
|
|
func (h *S3TablesHandler) lookupEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) (*filer_pb.Entry, error) {
|
|
dir, name := splitPath(path)
|
|
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: dir,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Entry, nil
|
|
}
|
|
|
|
// deleteExtendedAttribute deletes an extended attribute from an entry
|
|
func (h *S3TablesHandler) deleteExtendedAttribute(ctx context.Context, client filer_pb.SeaweedFilerClient, path, key string) error {
|
|
return h.mutateEntryExtended(ctx, client, path, func(extended map[string][]byte) error {
|
|
delete(extended, key)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// deleteDirectory deletes a directory and all its contents
|
|
func (h *S3TablesHandler) deleteDirectory(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) error {
|
|
dir, name := splitPath(path)
|
|
return filer_pb.DoRemove(ctx, client, dir, name, true, true, true, false, nil)
|
|
}
|
|
|
|
// deleteDirectoryIfMatches deletes a directory and all its contents only
|
|
// while the entry still carries the extended attributes the caller captured:
|
|
// the filer compares and removes in one operation under the path lock, so an
|
|
// attribute write that keeps Mtime or a delete+recreate in between refuses the
|
|
// delete with ErrConcurrentUpdate instead of losing the replacement.
|
|
func (h *S3TablesHandler) deleteDirectoryIfMatches(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, expected *filer_pb.Entry) error {
|
|
dir, name := splitPath(path)
|
|
_, err := filer_pb.DoRemoveRequest(ctx, client, &filer_pb.DeleteEntryRequest{
|
|
Directory: dir,
|
|
Name: name,
|
|
IsDeleteData: true,
|
|
IsRecursive: true,
|
|
IgnoreRecursiveError: true,
|
|
ExpectedExtended: snapshotExtended(expected.Extended),
|
|
})
|
|
if status.Code(err) == codes.FailedPrecondition {
|
|
return fmt.Errorf("%w: %s", ErrConcurrentUpdate, path)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// entryExists checks if an entry exists at the given path
|
|
func (h *S3TablesHandler) entryExists(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) bool {
|
|
dir, name := splitPath(path)
|
|
_, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: dir,
|
|
Name: name,
|
|
})
|
|
return err == nil
|
|
}
|