Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/artifact-cas/internal/service/bytestream.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ func (s *ByteStreamService) Write(stream bytestream.ByteStream_WriteServer) erro
return kerrors.BadRequest("resource name", err.Error())
}

// A token scoped to a digest can only write that digest. Checked before the
// existence lookup so the token can not probe for other artifacts.
if err := info.CheckDigest(req.resource.Digest); err != nil {
return kerrors.Forbidden("service", err.Error())
}

// A blob cached as present skips the secrets manager and the backend. The
// key is namespaced by the organization and backend of the signed token.
existenceKey := s.existenceKey(info, req.resource.Digest)
Expand Down Expand Up @@ -332,6 +338,11 @@ func (s *ByteStreamService) Read(req *bytestream.ReadRequest, stream bytestream.
return kerrors.BadRequest("resource name", "empty resource name")
}

// A token scoped to a digest can only read that digest
if err := info.CheckDigest(req.ResourceName); err != nil {
return kerrors.Forbidden("service", err.Error())
}

backend, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID)
if err != nil && kerrors.IsNotFound(err) {
return err
Expand Down
88 changes: 88 additions & 0 deletions app/artifact-cas/internal/service/bytestream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,51 @@ func (s *bytestreamSuite) TestWriteOK() {
s.Equal(resource.FileName, info.FileName)
}

// otherDigest is a digest a scoped token is not authorized for
const otherDigest = "7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730"

func (s *bytestreamSuite) TestWriteAuthorizedDigest() {
data := []byte("hello world")
resource := resourceWithDigest(data, "skynet.exe")

testCases := []struct {
name string
authorizedDigest string
wantErr bool
}{
{name: "token scoped to the digest", authorizedDigest: "sha256:" + resource.Digest},
{name: "token scoped to another digest", authorizedDigest: "sha256:" + otherDigest, wantErr: true},
}

for _, tc := range testCases {
s.Run(tc.name, func() {
// the mock fails the test on any backend call for a forbidden digest
if !tc.wantErr {
s.ociBackend.On("Exists", mock.Anything, resource.Digest).Return(false, nil)
s.ociBackend.On("Upload", mock.Anything, mock.Anything, resource).Return(nil)
}

ctx := metadata.AppendToOutgoingContext(s.upCtx, "authorized-digest", tc.authorizedDigest)
stream, err := s.client.Write(ctx)
s.Require().NoError(err)
_ = stream.Send(&bytestream.WriteRequest{
ResourceName: encodeResource(s.T(), resource),
Data: data,
})

got, err := stream.CloseAndRecv()
if tc.wantErr {
assertGRPCError(s.T(), err, codes.PermissionDenied, "not authorized for this digest")
s.Empty(s.audit.published)
return
}

s.Require().NoError(err)
s.Equal(int64(len(data)), got.CommittedSize)
})
}
}

func (s *bytestreamSuite) TestWriteErrorUploading() {
data := []byte("hello world")
resource := resourceWithDigest(data, "skynet.exe")
Expand Down Expand Up @@ -389,6 +434,46 @@ func (s *bytestreamSuite) TestDownloadOk() {
s.False(info.Skipped)
}

func (s *bytestreamSuite) TestReadAuthorizedDigest() {
const digest = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"

testCases := []struct {
name string
authorizedDigest string
wantErr bool
}{
{name: "token scoped to the digest", authorizedDigest: "sha256:" + digest},
{name: "token scoped to another digest", authorizedDigest: "sha256:" + otherDigest, wantErr: true},
}

for _, tc := range testCases {
s.Run(tc.name, func() {
// the mock fails the test on a download of a forbidden digest
if !tc.wantErr {
s.ociBackend.On("Download", mock.Anything, mock.Anything, digest).
Return(nil).Run(func(args mock.Arguments) {
_, err := io.WriteString(args.Get(1).(io.Writer), "hello world")
s.NoError(err)
})
}

ctx := metadata.AppendToOutgoingContext(s.downCtx, "authorized-digest", tc.authorizedDigest)
reader, err := s.client.Read(ctx, &bytestream.ReadRequest{ResourceName: digest})
s.Require().NoError(err)

got, err := reader.Recv()
if tc.wantErr {
assertGRPCError(s.T(), err, codes.PermissionDenied, "not authorized for this digest")
s.Empty(s.audit.published)
return
}

s.Require().NoError(err)
s.Equal("hello world", string(got.Data))
})
}
}

func (s *bytestreamSuite) TestDownloadFoundMistmathedDigest() {
s.ociBackend.On("Download", mock.Anything, mock.Anything, "deadbeef").
Return(nil).Run(func(args mock.Arguments) {
Expand Down Expand Up @@ -487,6 +572,9 @@ func (s *bytestreamSuite) setup(opts ...NewOpt) {
if v := md.Get("org-id"); len(v) > 0 {
claims.OrgID = v[0]
}
if v := md.Get("authorized-digest"); len(v) > 0 {
claims.Digest = v[0]
}
if v := md.Get("source-internal"); len(v) > 0 {
claims.SourceInternal = true
}
Expand Down
6 changes: 6 additions & 0 deletions app/artifact-cas/internal/service/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ func (s *DownloadService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

// A token scoped to a digest can only read that digest
if err := auth.CheckDigest(wantChecksum.String()); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}

b, err := s.loadBackend(ctx, auth.BackendType, auth.StoredSecretID)
if err != nil && kerrors.IsNotFound(err) {
http.Error(w, "backend not found", http.StatusNotFound)
Expand Down
58 changes: 58 additions & 0 deletions app/artifact-cas/internal/service/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,61 @@ func TestDownloadServiceConditionalRequests(t *testing.T) {
})
}
}

func TestDownloadServiceAuthorizedDigest(t *testing.T) {
const otherDigest = "sha256:7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730"

tests := []struct {
name string
digest string
wantStatus int
}{
{name: "token without digest claim reads the artifact", wantStatus: http.StatusOK},
{name: "token scoped to the digest reads the artifact", digest: "sha256:" + downloadDigestHex, wantStatus: http.StatusOK},
{name: "token scoped to another digest is forbidden", digest: otherDigest, wantStatus: http.StatusForbidden},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
provider := mocks.NewProvider(t)
// the mocks fail the test on any backend call made for a forbidden digest
if tc.wantStatus == http.StatusOK {
uploaderDownloader := mocks.NewUploaderDownloader(t)
provider.On("FromCredentials", mock.Anything, mock.Anything).Return(uploaderDownloader, nil)
uploaderDownloader.On("Describe", mock.Anything, downloadDigestHex).
Return(&v1.CASResource{FileName: downloadFileName, Digest: downloadDigestHex, Size: int64(len(downloadContent))}, nil)
uploaderDownloader.On("Download", mock.Anything, mock.Anything, downloadDigestHex).Return(nil).
Run(func(args mock.Arguments) {
_, err := io.WriteString(args.Get(1).(io.Writer), downloadContent)
require.NoError(t, err)
})
}

svc := NewDownloadService(
backend.Providers{downloadBackendType: provider},
WithLogger(log.DefaultLogger),
WithStagingDir(t.TempDir()),
)

claims := &casJWT.Claims{
Role: casJWT.Downloader,
StoredSecretID: testStoredSecretID,
BackendType: downloadBackendType,
OrgID: testOrgID,
Digest: tc.digest,
}

req := httptest.NewRequest(http.MethodGet, "/download/sha256:"+downloadDigestHex, nil)
req = mux.SetURLVars(req, map[string]string{"digest": "sha256:" + downloadDigestHex})
req = req.WithContext(jwtMiddleware.NewContext(req.Context(), claims))

w := httptest.NewRecorder()
svc.ServeHTTP(w, req)

assert.Equal(t, tc.wantStatus, w.Code)
if tc.wantStatus == http.StatusOK {
assert.Equal(t, downloadContent, w.Body.String())
}
})
}
}
10 changes: 10 additions & 0 deletions app/artifact-cas/internal/service/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ func (s *ResourceService) Describe(ctx context.Context, req *v1.ResourceServiceD
return nil, err
}

// Only downloader tokens are allowed
if err := info.CheckRole(casJWT.Downloader); err != nil {
return nil, errors.Unauthorized("service", err.Error())
}

// A token scoped to a digest can only describe that digest
if err := info.CheckDigest(req.Digest); err != nil {
return nil, errors.Forbidden("service", err.Error())
}

b, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID)
if err != nil && errors.IsNotFound(err) {
return nil, err
Expand Down
52 changes: 50 additions & 2 deletions app/artifact-cas/internal/service/resource_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2023 The Chainloop Authors.
// Copyright 2023-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -24,6 +24,7 @@ import (
casjwt "github.com/chainloop-dev/chainloop/internal/robotaccount/cas"
backend "github.com/chainloop-dev/chainloop/pkg/blobmanager"
"github.com/chainloop-dev/chainloop/pkg/blobmanager/mocks"
"github.com/go-kratos/kratos/v2/errors"
jwtmiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
Expand All @@ -44,6 +45,48 @@ func (s *resourceSuite) TestDescribe() {
s.Equal(&v1.ResourceServiceDescribeResponse{Result: want}, got)
}

func (s *resourceSuite) TestDescribeAuthorization() {
const (
digest = "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"
otherDigest = "7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730"
)

testCases := []struct {
name string
role casjwt.Role
digest string
wantErr bool
}{
{name: "downloader without digest claim", role: casjwt.Downloader},
{name: "downloader scoped to the digest", role: casjwt.Downloader, digest: "sha256:" + digest},
{name: "downloader scoped to another digest", role: casjwt.Downloader, digest: "sha256:" + otherDigest, wantErr: true},
{name: "uploader", role: casjwt.Uploader, wantErr: true},
}

for _, tc := range testCases {
s.Run(tc.name, func() {
want := &v1.CASResource{FileName: "test.txt", Digest: digest}
// the mock fails the test on a describe of a forbidden digest
if !tc.wantErr {
s.ociBackend.On("Describe", mock.Anything, digest).Return(want, nil)
}

claims := &casjwt.Claims{StoredSecretID: "secret-id", BackendType: "backend1", Role: tc.role, Digest: tc.digest}
ctx := jwtmiddleware.NewContext(context.Background(), claims)
svc := service.NewResourceService(s.backendProviders)
got, err := svc.Describe(ctx, &v1.ResourceServiceDescribeRequest{Digest: digest})
if tc.wantErr {
s.True(errors.IsForbidden(err) || errors.IsUnauthorized(err), "unexpected error: %v", err)
s.Nil(got)
return
}

s.NoError(err)
s.Equal(want, got.GetResult())
})
}
}

type resourceSuite struct {
suite.Suite
ociBackend *mocks.UploaderDownloader
Expand All @@ -54,14 +97,19 @@ func (s *resourceSuite) SetupTest() {
ociBackendProvider := mocks.NewProvider(s.T())
ociBackend := mocks.NewUploaderDownloader(s.T())
ociBackendProvider.On("FromCredentials", mock.Anything, "secret-id").
Return(ociBackend, nil)
Maybe().Return(ociBackend, nil)

s.ociBackend = ociBackend
s.backendProviders = backend.Providers{
"backend1": ociBackendProvider,
}
}

// SetupSubTest gives every s.Run subtest its own backend mocks
func (s *resourceSuite) SetupSubTest() {
s.SetupTest()
}

// Run the tests
func TestResourceSuite(t *testing.T) {
suite.Run(t, new(resourceSuite))
Expand Down
4 changes: 3 additions & 1 deletion app/cli/cmd/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ func newArtifactCmd() *cobra.Command {
}

func wrappedArtifactConn(cpConn *grpc.ClientConn, role pb.CASCredentialsServiceGetRequest_Role, digest string) (*grpc.ClientConn, error) {
// Retrieve temporary credentials for uploading
// Retrieve temporary credentials for the operation
client := pb.NewCASCredentialsServiceClient(cpConn)
resp, err := client.Get(context.Background(), &pb.CASCredentialsServiceGetRequest{
Role: role,
Digest: digest,
// an upload or a download is for a single digest, so the token can only access that digest
ScopeToDigest: digest != "",
Comment thread
migmartri marked this conversation as resolved.
})
if err != nil {
return nil, err
Expand Down
Loading
Loading