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
23 changes: 17 additions & 6 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.

5 changes: 4 additions & 1 deletion app/controlplane/api/controlplane/v1/cas_credentials.proto
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2023-2025 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 @@ -35,6 +35,9 @@ message CASCredentialsServiceGetRequest {
}];
// during the download we need the digest to find the proper cas backend
string digest = 2;
// 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;

enum Role {
ROLE_UNSPECIFIED = 0;
Expand Down

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

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

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

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

25 changes: 23 additions & 2 deletions app/controlplane/internal/service/cascredential.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2023-2025 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 @@ -19,6 +19,7 @@ import (
"context"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
errors "github.com/go-kratos/kratos/v2/errors"
"github.com/google/uuid"
Expand Down Expand Up @@ -56,6 +57,12 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS
return nil, err
}

// Internal platform traffic can be flagged so the CAS skips audit event emission for it
sourceInternal, err := resolveSourceInternal(req.GetSourceInternal(), currentAPIToken)
if err != nil {
return nil, err
}

currentOrg, err := requireCurrentOrg(ctx)
if err != nil {
return nil, err
Expand Down Expand Up @@ -149,7 +156,7 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS
return nil, errors.BadRequest("invalid argument", "cannot upload or download artifacts from an inline CAS backend")
}

ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID}
ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID, SourceInternal: sourceInternal}
t, err := s.casUC.GenerateTemporaryCredentials(ref)
if err != nil {
return nil, handleUseCaseErr(err, s.log)
Expand All @@ -158,5 +165,19 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS
return &pb.CASCredentialsServiceGetResponse{
Result: &pb.CASCredentialsServiceGetResponse_Result{Token: t, Backend: bizCASBackendToPb(backend)},
}, nil
}

// resolveSourceInternal returns whether the minted CAS token must be flagged as internal
// platform traffic. Only system API tokens can request it since they are minted exclusively
// by internal code paths; any other caller asking for it is rejected.
func resolveSourceInternal(requested bool, token *entities.APIToken) (bool, error) {
if !requested {
return false, nil
}

if token == nil || !token.IsSystem {
return false, errors.Forbidden("forbidden", "source_internal is restricted to system API tokens")
}

return true, nil
}
86 changes: 86 additions & 0 deletions app/controlplane/internal/service/cascredential_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// Copyright 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package service

import (
"testing"

"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
"github.com/go-kratos/kratos/v2/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestResolveSourceInternal(t *testing.T) {
testCases := []struct {
name string
requested bool
token *entities.APIToken
want bool
wantErr bool
}{
{
name: "not requested, no token (user auth)",
requested: false,
token: nil,
want: false,
},
{
name: "not requested, regular API token",
requested: false,
token: &entities.APIToken{},
want: false,
},
{
name: "not requested, system API token",
requested: false,
token: &entities.APIToken{IsSystem: true},
want: false,
},
{
name: "requested by system API token",
requested: true,
token: &entities.APIToken{IsSystem: true},
want: true,
},
{
name: "requested by regular API token is forbidden",
requested: true,
token: &entities.APIToken{},
wantErr: true,
},
{
name: "requested by user auth is forbidden",
requested: true,
token: nil,
wantErr: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveSourceInternal(tc.requested, tc.token)
if tc.wantErr {
require.Error(t, err)
assert.True(t, errors.IsForbidden(err))
return
}

require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
WorkflowName: token.WorkflowName,
Policies: token.Policies,
Scope: scope,
IsSystem: token.IsSystem,
})

// Set the authorization subject that will be used to check the policies
Expand Down
2 changes: 2 additions & 0 deletions app/controlplane/internal/usercontext/entities/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ type APIToken struct {
// ACL policies for this token. Used for authorization checks.
Policies []*authz.Policy
Scope string
// IsSystem marks tokens minted by internal code paths; these are hidden from the public API.
IsSystem bool
}

func WithCurrentAPIToken(ctx context.Context, token *APIToken) context.Context {
Expand Down
Loading