Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for presigned URLs in the lakeFS API #4985

Merged
merged 14 commits into from
Jan 12, 2023
Merged

Conversation

ozkatz
Copy link
Collaborator

@ozkatz ozkatz commented Jan 9, 2023

This one requires discussion :)

Currently, lakeFS users have 2 options when reading and writing data:

  1. Involve lakeFS in the data path: this typically means either using the s3 gateway to read/write data, or to use the data operations in the lakeFS API (get_object, put_object)
  2. Use lakeFS for metadata only, with direct read/write to the underlying object store.

This requires a pretty hard tradeoff: (1) has implications regarding security (data going through the lakeFS server) as well as added operational complexity (i.e. the need to size a lakeFS installation for the required bandwidth) and potentially cost implications if the lakeFS installation is outside the AWS account and VPC used by its consumers (such as in lakeFS Cloud).

As for (2), it has a different set of problems: Once the metadata operation is done, direct access to the object store requires authentication and authorization. This means managing 2 different auth* systems: one for lakeFS and one for the underlying object store. Keeping both of them in sync is very hard (i.e. how do I give every user permissions to read only the exact underlying objects in a repository?)

This PR introduces pre-signed URLs as a middle ground. It allows receiving short-lived pre-signed URLs from GetObject, StatObject and ListObjects with the optional presign=true query parameter, as well as limited write support using GetPhysicalAddress where the address returned is pre-signed for a write operation.

This PR currently does not handle Multipart uploads, this could be added later (and is probably a requirement before it resolves #3160)


This is currently in draft and requires discussion. I don't plan on merging this unless we agree this is desired.

@ozkatz ozkatz added the include-changelog PR description should be included in next release changelog label Jan 9, 2023
@ozkatz ozkatz linked an issue Jan 9, 2023 that may be closed by this pull request
@ozkatz ozkatz added area/API Improvements or additions to the API new-feature Issues that introduce new feature or capability labels Jan 9, 2023
@arielshaqed
Copy link
Contributor

Yay, this is a great direction!

One worry is how to use this from lakeFSFS, because I would really like to use this from lakeFSFS. While this is certainly not the only client, it's a pretty important one. Here I am not sure how to use this and get the optimizations from S3A and other underlying FileSystems.

Copy link
Contributor

@arielshaqed arielshaqed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat stuff! Can you open an issue optionally to rewrite lakectl fs {upload,cat} --direct to use this redirecting mode? It will make it so much simpler and more natural!

Approving as a satisfied future user of this, obviously not as a versioning engine team member.

(Still not sure how to use it efficiently in lakeFSFS, but that's a different matter!)

writeError(w, r, http.StatusInternalServerError, err)
return
}
response.PresignedUrl = StringPtr(preSignedURL)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think

Suggested change
response.PresignedUrl = StringPtr(preSignedURL)
response.PresignedUrl = &preSignedURL

is more readable as well as more efficient.

Comment on lines +3143 to +3152
if authResponse.Allowed {
objStat.PhysicalAddress, err = c.BlockAdapter.GetPreSignedURL(ctx, block.ObjectPointer{
StorageNamespace: repo.StorageNamespace,
Identifier: entry.PhysicalAddress,
IdentifierType: entry.AddressType.ToIdentifierType(),
}, block.PreSignModeRead)
if c.handleAPIError(ctx, w, r, err) {
return
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I think of it this is really neat: I will be able to read all contents of an entire directory with a single listObjects on lakeFS.

Comment on lines +2891 to +2896
parameters:
- in: query
name: presign
required: false
schema:
type: boolean
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this should be a POST endpoint that redirects!

Copy link
Contributor

@nopcoder nopcoder left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool! Added some comments.
And general question - do we want the pre sign duration configuration controlled in one location / settings?

Permissions: permissions.String(),
}

keyCredentials, ok := a.credentials.(*azblob.SharedKeyCredential)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be done once at NewAdater and leave here just pointer check.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// Since this is a blob SAS, the URL is to the Azure storage blob.
qp := sasQueryParams.Encode()
return fmt.Sprintf(preSignedBlobPattern,
a.credentials, qualifiedKey.ContainerName, qualifiedKey.BlobURL, qp), nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We depends on credentials - string format of a type we don't control? Just making sure we do not need to call any explicit function to format the string.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, it's supposed to be the account name extracted from the credentials! Fixed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is it safe to return a string containing the credentials?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@itaiad200 you caught this too, changed :)

@ozkatz ozkatz requested a review from nopcoder January 11, 2023 11:03
Copy link
Contributor

@itaiad200 itaiad200 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks - I think this can bring value to our users!

return
}
if authResponse.Allowed {
objStat.PhysicalAddress, err = c.BlockAdapter.GetPreSignedURL(ctx, block.ObjectPointer{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC the default is 1000 objects in ListObjects. It means you're adding 1000 sequential calls to aws in a single lakeFS call. Doesn't seem like it can really scale. We've seen cases of users performing a >300 RPS of ListObjects, if we're planning to continue supporting this scale, we need to find other solutions for ListObjects. Can we get all 1000 links in a single API call to aws?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also consider the error rate. If AWS has 4 9s', the probability of 1000 calls to succeed is ~90%.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetPreSignedURL Doesn't do any network calls.
Pre-signed URLs are constructed locally by the adapter

@@ -29,21 +29,37 @@ const (
defaultMaxRetryRequests = 0
AuthMethodAccessKey = "access-key"
AuthMethodMSI = "msi"

defaultPreSignedURLDuration = time.Minute * 15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be a block package-level const?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call - moved

@@ -333,6 +333,21 @@ func (c *Controller) GetPhysicalAddress(w http.ResponseWriter, r *http.Request,
PhysicalAddress: StringPtr(qk.Format()),
Token: StringValue(token),
}

if params.Presign != nil && *params.Presign {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice that the above was changed with the merge of #4900

return a.getPreSignedURL(ctx, obj, permissions)
}

func (a *Adapter) getPreSignedURL(ctx context.Context, obj block.ObjectPointer, permissions azblob.BlobSASPermissions) (string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up @N-o-Z @guy-har who are working on Azure gen v2 now

// Since this is a blob SAS, the URL is to the Azure storage blob.
qp := sasQueryParams.Encode()
return fmt.Sprintf(preSignedBlobPattern,
a.credentials, qualifiedKey.ContainerName, qualifiedKey.BlobURL, qp), nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is it safe to return a string containing the credentials?

@@ -21,6 +21,8 @@ const (
delimiter = "/"
partSuffix = ".part_"
markerSuffix = ".multipart"

defaultPreSignedURLDuration = time.Minute * 15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here - if we define it equally in 2 different places, let's move it up the packages stack.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved

@@ -29,6 +29,9 @@ const (

// Chunks smaller than that are only allowed for the last chunk upload
minChunkSize = 8 * 1024

// DefaultPreSignedURLDuration determines how long should pre-signed URLs be valid for
DefaultPreSignedURLDuration = time.Minute * 15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved

"github.com/treeverse/lakefs/pkg/api"
)

func TestPreSign(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding tests for the Stat operation! Can we also add tests to all other new operations?
Also - let's validate that the links work as expected too

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right - I'm still trying to get this test to pass via Esti, no luck yet :( will add the additional tests for links as well once I do.

api/swagger.yml Outdated
@@ -3286,6 +3318,11 @@ paths:
schema:
type: string
description: path to the table data
- in: query
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need it in symlinks?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! We do not. removed

@ozkatz ozkatz requested a review from itaiad200 January 11, 2023 13:11
@ozkatz ozkatz marked this pull request as ready for review January 11, 2023 14:46
Comment on lines 15 to 46
blockStoreType := viper.GetViper().GetString("blockstore_type")
expected := ""
switch blockStoreType {
case block.BlockstoreTypeS3:
expected = "X-Amz-Signature="
case block.BlockstoreTypeGS:
expected = "X-Goog-Signature="
case block.BlockstoreTypeAzure:
expected = "sv="
default:
t.Skip("Only GS, S3 and Azure Blob supported for pre-signed urls")
}

ctx, _, repo := setupTest(t)
defer tearDownTest(repo)
_, _ = uploadFileRandomData(ctx, t, repo, mainBranch, "foo/bar", true)

// Get Object
preSign := true
response, err := client.StatObjectWithResponse(ctx, repo, mainBranch, &api.StatObjectParams{
Path: "foo/bar",
Presign: &preSign,
})
require.NoError(t, err, "failed to stat object with presign=true")
signedUrl := response.JSON200.PhysicalAddress
if !strings.HasPrefix(signedUrl, "http") {
t.Errorf("exected an HTTP(s) URL instead of an object store URI, got %s", signedUrl)
}

if !strings.Contains(signedUrl, expected) {
t.Errorf("expected a signed URL, got %s", signedUrl)
}
Copy link
Contributor

@nopcoder nopcoder Jan 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
blockStoreType := viper.GetViper().GetString("blockstore_type")
expected := ""
switch blockStoreType {
case block.BlockstoreTypeS3:
expected = "X-Amz-Signature="
case block.BlockstoreTypeGS:
expected = "X-Goog-Signature="
case block.BlockstoreTypeAzure:
expected = "sv="
default:
t.Skip("Only GS, S3 and Azure Blob supported for pre-signed urls")
}
ctx, _, repo := setupTest(t)
defer tearDownTest(repo)
_, _ = uploadFileRandomData(ctx, t, repo, mainBranch, "foo/bar", true)
// Get Object
preSign := true
response, err := client.StatObjectWithResponse(ctx, repo, mainBranch, &api.StatObjectParams{
Path: "foo/bar",
Presign: &preSign,
})
require.NoError(t, err, "failed to stat object with presign=true")
signedUrl := response.JSON200.PhysicalAddress
if !strings.HasPrefix(signedUrl, "http") {
t.Errorf("exected an HTTP(s) URL instead of an object store URI, got %s", signedUrl)
}
if !strings.Contains(signedUrl, expected) {
t.Errorf("expected a signed URL, got %s", signedUrl)
}
blockStoreType := viper.GetString("blockstore.type")
expectedKey := ""
switch blockStoreType {
case block.BlockstoreTypeS3:
expectedKey = "X-Amz-Signature"
case block.BlockstoreTypeGS:
expectedKey = "X-Goog-Signature"
case block.BlockstoreTypeAzure:
expectedKey = "sv"
default:
t.Skip("Only GS, S3 and Azure Blob supported for pre-signed urls")
}
ctx, _, repo := setupTest(t)
defer tearDownTest(repo)
_, _ = uploadFileRandomData(ctx, t, repo, mainBranch, "foo/bar", true)
response, err := client.StatObjectWithResponse(ctx, repo, mainBranch, &api.StatObjectParams{
Path: "foo/bar",
Presign: swag.Bool(true),
})
require.NoError(t, err, "failed to stat object with presign=true")
require.NotNil(t, response.JSON200, "successful response")
signedURL := response.JSON200.PhysicalAddress
parsedSignedURL, err := url.Parse(signedURL)
require.NoErrorf(t, err, "failed to parse url - %s", signedURL)
require.Truef(t, strings.HasPrefix(parsedSignedURL.Scheme, "http"), "URL scheme http(s) - %s", signedURL)
require.NotEmptyf(t, parsedSignedURL.Query().Get(expectedKey), "signature expected in key '%s' - %s", expectedKey, signedURL)

Copy link
Contributor

@nopcoder nopcoder left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! - additional comments for your consideration

@ozkatz ozkatz enabled auto-merge (squash) January 12, 2023 17:05
@ozkatz ozkatz disabled auto-merge January 12, 2023 17:46
@ozkatz ozkatz dismissed itaiad200’s stale review January 12, 2023 17:47

Changes applied.

@ozkatz ozkatz merged commit 0d9c665 into master Jan 12, 2023
@ozkatz ozkatz deleted the feature/presigned branch January 12, 2023 17:47
@itaiad200
Copy link
Contributor

@ozkatz Still missing tests for most of the APIs to get the links, including validations that links actually work.

@ozkatz
Copy link
Collaborator Author

ozkatz commented Jan 13, 2023

You're right @itaiad200 - Merged too early. Tests added in #5035

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/API Improvements or additions to the API include-changelog PR description should be included in next release changelog new-feature Issues that introduce new feature or capability
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Direct-access FileSystem to be credential-free
4 participants