From b8eb48078f0a038730c977c7ae3cd71031855d27 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Thu, 11 Jun 2026 17:35:12 +0200 Subject: [PATCH] feat(controlplane): allow system tokens to flag CAS credentials as internal Add an opt-in source_internal field to CASCredentialsServiceGetRequest that marks the minted CAS token as internal platform traffic so the Artifact CAS skips audit event emission for it. The flag is only honored when the caller authenticates with a system API token; any other caller requesting it is rejected. Closes #3198 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: a7b18cae-6c02-4a48-9f17-dd88f510e6f2 --- .../api/controlplane/v1/cas_credentials.pb.go | 23 +++-- .../api/controlplane/v1/cas_credentials.proto | 5 +- .../v1/cas_credentials_grpc.pb.go | 2 +- .../controlplane/v1/cas_credentials.ts | 20 ++++- ...edentialsServiceGetRequest.jsonschema.json | 10 +++ ...ASCredentialsServiceGetRequest.schema.json | 10 +++ .../internal/service/cascredential.go | 25 +++++- .../internal/service/cascredential_test.go | 86 +++++++++++++++++++ .../usercontext/apitoken_middleware.go | 1 + .../internal/usercontext/entities/apitoken.go | 2 + 10 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 app/controlplane/internal/service/cascredential_test.go 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 {