diff --git a/app/controlplane/api/controlplane/v1/cas_credentials.pb.go b/app/controlplane/api/controlplane/v1/cas_credentials.pb.go index 7563b9963..c271ac4a5 100644 --- a/app/controlplane/api/controlplane/v1/cas_credentials.pb.go +++ b/app/controlplane/api/controlplane/v1/cas_credentials.pb.go @@ -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. @@ -90,9 +90,12 @@ type CASCredentialsServiceGetRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Role CASCredentialsServiceGetRequest_Role `protobuf:"varint,1,opt,name=role,proto3,enum=controlplane.v1.CASCredentialsServiceGetRequest_Role" json:"role,omitempty"` // during the download we need the digest to find the proper cas backend - Digest string `protobuf:"bytes,2,opt,name=digest,proto3" json:"digest,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Digest string `protobuf:"bytes,2,opt,name=digest,proto3" json:"digest,omitempty"` + // 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. + SourceInternal bool `protobuf:"varint,3,opt,name=source_internal,json=sourceInternal,proto3" json:"source_internal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CASCredentialsServiceGetRequest) Reset() { @@ -139,6 +142,13 @@ func (x *CASCredentialsServiceGetRequest) GetDigest() string { return "" } +func (x *CASCredentialsServiceGetRequest) GetSourceInternal() bool { + if x != nil { + return x.SourceInternal + } + return false +} + type CASCredentialsServiceGetResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Result *CASCredentialsServiceGetResponse_Result `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` @@ -239,11 +249,12 @@ var File_controlplane_v1_cas_credentials_proto protoreflect.FileDescriptor const file_controlplane_v1_cas_credentials_proto_rawDesc = "" + "\n" + - "%controlplane/v1/cas_credentials.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a'controlplane/v1/response_messages.proto\"\xd6\x01\n" + + "%controlplane/v1/cas_credentials.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a'controlplane/v1/response_messages.proto\"\xff\x01\n" + "\x1fCASCredentialsServiceGetRequest\x12U\n" + "\x04role\x18\x01 \x01(\x0e25.controlplane.v1.CASCredentialsServiceGetRequest.RoleB\n" + "\xbaH\a\x82\x01\x04\x18\x01\x18\x02R\x04role\x12\x16\n" + - "\x06digest\x18\x02 \x01(\tR\x06digest\"D\n" + + "\x06digest\x18\x02 \x01(\tR\x06digest\x12'\n" + + "\x0fsource_internal\x18\x03 \x01(\bR\x0esourceInternal\"D\n" + "\x04Role\x12\x14\n" + "\x10ROLE_UNSPECIFIED\x10\x00\x12\x13\n" + "\x0fROLE_DOWNLOADER\x10\x01\x12\x11\n" + diff --git a/app/controlplane/api/controlplane/v1/cas_credentials.proto b/app/controlplane/api/controlplane/v1/cas_credentials.proto index 6ba0fa9b7..04cbaf443 100644 --- a/app/controlplane/api/controlplane/v1/cas_credentials.proto +++ b/app/controlplane/api/controlplane/v1/cas_credentials.proto @@ -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. @@ -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; diff --git a/app/controlplane/api/controlplane/v1/cas_credentials_grpc.pb.go b/app/controlplane/api/controlplane/v1/cas_credentials_grpc.pb.go index 67a8eb7ea..94e7ba4d6 100644 --- a/app/controlplane/api/controlplane/v1/cas_credentials_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/cas_credentials_grpc.pb.go @@ -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. diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/cas_credentials.ts b/app/controlplane/api/gen/frontend/controlplane/v1/cas_credentials.ts index 46cc1b0cc..9546d91d1 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/cas_credentials.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/cas_credentials.ts @@ -10,6 +10,11 @@ export interface CASCredentialsServiceGetRequest { role: CASCredentialsServiceGetRequest_Role; /** during the download we need the digest to find the proper cas backend */ digest: string; + /** + * 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. + */ + sourceInternal: boolean; } export enum CASCredentialsServiceGetRequest_Role { @@ -61,7 +66,7 @@ export interface CASCredentialsServiceGetResponse_Result { } function createBaseCASCredentialsServiceGetRequest(): CASCredentialsServiceGetRequest { - return { role: 0, digest: "" }; + return { role: 0, digest: "", sourceInternal: false }; } export const CASCredentialsServiceGetRequest = { @@ -72,6 +77,9 @@ export const CASCredentialsServiceGetRequest = { if (message.digest !== "") { writer.uint32(18).string(message.digest); } + if (message.sourceInternal === true) { + writer.uint32(24).bool(message.sourceInternal); + } return writer; }, @@ -96,6 +104,13 @@ export const CASCredentialsServiceGetRequest = { message.digest = reader.string(); continue; + case 3: + if (tag !== 24) { + break; + } + + message.sourceInternal = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -109,6 +124,7 @@ export const CASCredentialsServiceGetRequest = { return { role: isSet(object.role) ? cASCredentialsServiceGetRequest_RoleFromJSON(object.role) : 0, digest: isSet(object.digest) ? String(object.digest) : "", + sourceInternal: isSet(object.sourceInternal) ? Boolean(object.sourceInternal) : false, }; }, @@ -116,6 +132,7 @@ export const CASCredentialsServiceGetRequest = { const obj: any = {}; message.role !== undefined && (obj.role = cASCredentialsServiceGetRequest_RoleToJSON(message.role)); message.digest !== undefined && (obj.digest = message.digest); + message.sourceInternal !== undefined && (obj.sourceInternal = message.sourceInternal); return obj; }, @@ -129,6 +146,7 @@ export const CASCredentialsServiceGetRequest = { const message = createBaseCASCredentialsServiceGetRequest(); message.role = object.role ?? 0; message.digest = object.digest ?? ""; + message.sourceInternal = object.sourceInternal ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.CASCredentialsServiceGetRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.CASCredentialsServiceGetRequest.jsonschema.json index bf09b332b..63fd56cf0 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.CASCredentialsServiceGetRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.CASCredentialsServiceGetRequest.jsonschema.json @@ -2,6 +2,12 @@ "$id": "controlplane.v1.CASCredentialsServiceGetRequest.jsonschema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "patternProperties": { + "^(source_internal)$": { + "description": "flag the minted token as internal platform traffic so the CAS skips audit\n event emission for it. Only honored for system API tokens, rejected otherwise.", + "type": "boolean" + } + }, "properties": { "digest": { "description": "during the download we need the digest to find the proper cas backend", @@ -24,6 +30,10 @@ "type": "integer" } ] + }, + "sourceInternal": { + "description": "flag the minted token as internal platform traffic so the CAS skips audit\n event emission for it. Only honored for system API tokens, rejected otherwise.", + "type": "boolean" } }, "title": "CAS Credentials Service Get Request", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.CASCredentialsServiceGetRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.CASCredentialsServiceGetRequest.schema.json index 6320cdb30..a85a0e163 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.CASCredentialsServiceGetRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.CASCredentialsServiceGetRequest.schema.json @@ -2,6 +2,12 @@ "$id": "controlplane.v1.CASCredentialsServiceGetRequest.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "patternProperties": { + "^(sourceInternal)$": { + "description": "flag the minted token as internal platform traffic so the CAS skips audit\n event emission for it. Only honored for system API tokens, rejected otherwise.", + "type": "boolean" + } + }, "properties": { "digest": { "description": "during the download we need the digest to find the proper cas backend", @@ -24,6 +30,10 @@ "type": "integer" } ] + }, + "source_internal": { + "description": "flag the minted token as internal platform traffic so the CAS skips audit\n event emission for it. Only honored for system API tokens, rejected otherwise.", + "type": "boolean" } }, "title": "CAS Credentials Service Get Request", diff --git a/app/controlplane/internal/service/cascredential.go b/app/controlplane/internal/service/cascredential.go index cfec6038b..13108feb3 100644 --- a/app/controlplane/internal/service/cascredential.go +++ b/app/controlplane/internal/service/cascredential.go @@ -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. @@ -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" @@ -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 @@ -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) @@ -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 } diff --git a/app/controlplane/internal/service/cascredential_test.go b/app/controlplane/internal/service/cascredential_test.go new file mode 100644 index 000000000..04a4c54f1 --- /dev/null +++ b/app/controlplane/internal/service/cascredential_test.go @@ -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) + }) + } +} diff --git a/app/controlplane/internal/usercontext/apitoken_middleware.go b/app/controlplane/internal/usercontext/apitoken_middleware.go index 7ff52b7b0..003762420 100644 --- a/app/controlplane/internal/usercontext/apitoken_middleware.go +++ b/app/controlplane/internal/usercontext/apitoken_middleware.go @@ -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 diff --git a/app/controlplane/internal/usercontext/entities/apitoken.go b/app/controlplane/internal/usercontext/entities/apitoken.go index 2f68eb736..105f71534 100644 --- a/app/controlplane/internal/usercontext/entities/apitoken.go +++ b/app/controlplane/internal/usercontext/entities/apitoken.go @@ -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 {