Skip to content
Open
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
5 changes: 5 additions & 0 deletions app/artifact-cas/internal/service/bytestream.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,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
46 changes: 46 additions & 0 deletions app/artifact-cas/internal/service/bytestream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,49 @@ func (s *bytestreamSuite) TestDownloadOk() {
s.False(info.Skipped)
}

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

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 +530,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
2 changes: 2 additions & 0 deletions app/cli/cmd/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ func wrappedArtifactConn(cpConn *grpc.ClientConn, role pb.CASCredentialsServiceG
resp, err := client.Get(context.Background(), &pb.CASCredentialsServiceGetRequest{
Role: role,
Digest: digest,
// a download is for a single digest, so the token can only read that digest
ScopeToDigest: role == pb.CASCredentialsServiceGetRequest_ROLE_DOWNLOADER && digest != "",
Comment thread
migmartri marked this conversation as resolved.
})
if err != nil {
return nil, err
Expand Down
19 changes: 15 additions & 4 deletions app/controlplane/api/controlplane/v1/cas_credentials.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions app/controlplane/api/controlplane/v1/cas_credentials.proto
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ message CASCredentialsServiceGetRequest {
// flag the minted token as internal platform traffic so the CAS skips audit
// event emission for it. Only honored for system API tokens, rejected otherwise.
bool source_internal = 3;
// scope the downloader token to the requested digest, so the CAS rejects reads of any other artifact.
// Requires the downloader role and a digest.
bool scope_to_digest = 4;

enum Role {
ROLE_UNSPECIFIED = 0;
Expand Down
20 changes: 15 additions & 5 deletions app/controlplane/api/controlplane/v1/cas_redirect.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion app/controlplane/api/controlplane/v1/cas_redirect.proto
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 Down Expand Up @@ -28,6 +28,8 @@ service CASRedirectService {

message GetDownloadURLRequest {
string digest = 1 [(buf.validate.field).string = {min_len: 1}];
// scope the token in the URL to the requested digest, so the CAS rejects reads of any other artifact
bool scope_to_digest = 2;
}

message GetDownloadURLResponse {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading