From fb12bcaca2ac0926c00f0a8565e0e3d372e5e790 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 15 May 2026 13:51:21 +0200 Subject: [PATCH 01/18] feat(blobmanager): add S3 Access Point provider for managed CAS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new `AWS-S3-ACCESS-POINT` CAS backend that targets a single shared bucket via per-tenant S3 Access Points. Each upload/download mints scoped temporary credentials via `sts:AssumeRole` with a session policy narrowed to the tenant's AP ARN and key prefix, and a session name derived from the authenticated requesting org carried in `ctx` (`s3accesspoint.WithRequestingOrg`). Both upstream binaries pick up a new optional `blob_backends.s3_access_point` config block (`base_role_arn`, `region`, `session_duration`); when the block is absent the provider stays unregistered and behaviour is identical to before. The pod's ambient AWS identity (IRSA / instance profile / env vars) is used to call STS — no static credentials live in config. Per-tenant data (AP ARN, region override, key prefix) is stored as a JSON blob in the secrets manager and read via `FromCredentials`, so the existing `backend.Provider` interface is unchanged. Add `OrgID` to the CAS robotaccount JWT claims so artifact-cas can enrich its context with the requesting org before invoking the backend; existing providers ignore the key. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- app/artifact-cas/cmd/wire.go | 22 + app/artifact-cas/cmd/wire_gen.go | 24 +- app/artifact-cas/configs/config.devel.yaml | 10 + app/artifact-cas/internal/conf/conf.pb.go | 235 +++++++--- app/artifact-cas/internal/conf/conf.proto | 18 + app/artifact-cas/internal/server/grpc_test.go | 2 +- .../internal/service/bytestream.go | 4 +- app/artifact-cas/internal/service/download.go | 2 +- app/artifact-cas/internal/service/resource.go | 2 +- app/artifact-cas/internal/service/service.go | 29 +- app/controlplane/cmd/wire.go | 23 +- app/controlplane/cmd/wire_gen.go | 24 +- app/controlplane/configs/config.devel.yaml | 12 + .../conf/controlplane/config/v1/conf.pb.go | 415 ++++++++++++------ .../conf/controlplane/config/v1/conf.proto | 29 ++ .../internal/service/attestation.go | 2 +- .../internal/service/cascredential.go | 2 +- .../internal/service/casredirect.go | 2 +- app/controlplane/pkg/biz/casbackend.go | 3 +- app/controlplane/pkg/biz/cascredentials.go | 6 +- .../pkg/data/ent/casbackend/casbackend.go | 2 +- .../pkg/data/ent/migrate/schema.go | 2 +- go.mod | 4 +- go.sum | 6 + internal/robotaccount/cas/robotaccount.go | 17 +- .../robotaccount/cas/robotaccount_test.go | 5 +- pkg/blobmanager/loader/loader.go | 51 ++- pkg/blobmanager/loader/loader_test.go | 70 +++ pkg/blobmanager/s3accesspoint/backend.go | 335 ++++++++++++++ pkg/blobmanager/s3accesspoint/backend_test.go | 165 +++++++ pkg/blobmanager/s3accesspoint/provider.go | 241 ++++++++++ .../s3accesspoint/provider_test.go | 198 +++++++++ 32 files changed, 1747 insertions(+), 215 deletions(-) create mode 100644 pkg/blobmanager/loader/loader_test.go create mode 100644 pkg/blobmanager/s3accesspoint/backend.go create mode 100644 pkg/blobmanager/s3accesspoint/backend_test.go create mode 100644 pkg/blobmanager/s3accesspoint/provider.go create mode 100644 pkg/blobmanager/s3accesspoint/provider_test.go diff --git a/app/artifact-cas/cmd/wire.go b/app/artifact-cas/cmd/wire.go index d9c2d823f..e16cf0381 100644 --- a/app/artifact-cas/cmd/wire.go +++ b/app/artifact-cas/cmd/wire.go @@ -25,6 +25,7 @@ import ( "github.com/chainloop-dev/chainloop/app/artifact-cas/internal/server" "github.com/chainloop-dev/chainloop/app/artifact-cas/internal/service" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/credentials" "github.com/go-kratos/kratos/v2/log" "github.com/google/wire" @@ -37,6 +38,8 @@ func wireApp(*conf.Bootstrap, *conf.Server, *conf.Auth, credentials.Reader, log. server.ProviderSet, service.ProviderSet, loader.LoadProviders, + newLoaderOptions, + wire.FieldsOf(new(*conf.Bootstrap), "BlobBackends"), newApp, serviceOpts, newProtoValidator, @@ -44,6 +47,25 @@ func wireApp(*conf.Bootstrap, *conf.Server, *conf.Auth, credentials.Reader, log. ) } +// newLoaderOptions builds the loader.Options struct from the deployment +// Bootstrap. When `blob_backends.s3_access_point` is absent (the common +// case for on-prem) S3AccessPoint stays nil and the provider is not +// registered, leaving the binary's behaviour identical to the pre-managed +// CAS world. +func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { + opts := &loader.Options{Logger: l} + if in == nil || in.GetS3AccessPoint() == nil { + return opts + } + ap := in.GetS3AccessPoint() + opts.S3AccessPoint = &s3accesspoint.Config{ + BaseRoleARN: ap.GetBaseRoleArn(), + Region: ap.GetRegion(), + SessionDuration: ap.GetSessionDuration().AsDuration(), + } + return opts +} + func serviceOpts(l log.Logger) []service.NewOpt { return []service.NewOpt{ service.WithLogger(l), diff --git a/app/artifact-cas/cmd/wire_gen.go b/app/artifact-cas/cmd/wire_gen.go index 07dba05dc..4ef780ede 100644 --- a/app/artifact-cas/cmd/wire_gen.go +++ b/app/artifact-cas/cmd/wire_gen.go @@ -11,6 +11,7 @@ import ( "github.com/chainloop-dev/chainloop/app/artifact-cas/internal/server" "github.com/chainloop-dev/chainloop/app/artifact-cas/internal/service" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/credentials" "github.com/go-kratos/kratos/v2/log" ) @@ -23,7 +24,9 @@ import ( // wireApp init kratos application. func wireApp(bootstrap *conf.Bootstrap, confServer *conf.Server, auth *conf.Auth, reader credentials.Reader, logger log.Logger) (*app, func(), error) { - providers := loader.LoadProviders(reader) + blobBackends := bootstrap.BlobBackends + options := newLoaderOptions(blobBackends, logger) + providers := loader.LoadProviders(reader, options) v := serviceOpts(logger) byteStreamService := service.NewByteStreamService(providers, v...) resourceService := service.NewResourceService(providers, v...) @@ -56,6 +59,25 @@ func wireApp(bootstrap *conf.Bootstrap, confServer *conf.Server, auth *conf.Auth // wire.go: +// newLoaderOptions builds the loader.Options struct from the deployment +// Bootstrap. When `blob_backends.s3_access_point` is absent (the common +// case for on-prem) S3AccessPoint stays nil and the provider is not +// registered, leaving the binary's behaviour identical to the pre-managed +// CAS world. +func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { + opts := &loader.Options{Logger: l} + if in == nil || in.GetS3AccessPoint() == nil { + return opts + } + ap := in.GetS3AccessPoint() + opts.S3AccessPoint = &s3accesspoint.Config{ + BaseRoleARN: ap.GetBaseRoleArn(), + Region: ap.GetRegion(), + SessionDuration: ap.GetSessionDuration().AsDuration(), + } + return opts +} + func serviceOpts(l log.Logger) []service.NewOpt { return []service.NewOpt{service.WithLogger(l)} } diff --git a/app/artifact-cas/configs/config.devel.yaml b/app/artifact-cas/configs/config.devel.yaml index 63c53c6cd..a86e4d333 100644 --- a/app/artifact-cas/configs/config.devel.yaml +++ b/app/artifact-cas/configs/config.devel.yaml @@ -39,3 +39,13 @@ observability: auth: public_key_path: ${PUBLIC_KEY_PATH:../../devel/devkeys/cas.pub} + +# Optional managed CAS provider (S3 Access Points). Mirrors the +# controlplane's blob_backends block — both binaries must agree on the +# settings since each independently instantiates the provider. Leave +# commented out for on-prem deployments that don't use managed CAS. +# blob_backends: +# s3_access_point: +# base_role_arn: arn:aws:iam::123456789012:role/chainloop-cas-tenant +# region: us-east-1 +# session_duration: 1h diff --git a/app/artifact-cas/internal/conf/conf.pb.go b/app/artifact-cas/internal/conf/conf.pb.go index a29ee28e9..e623537f2 100644 --- a/app/artifact-cas/internal/conf/conf.pb.go +++ b/app/artifact-cas/internal/conf/conf.pb.go @@ -44,8 +44,12 @@ type Bootstrap struct { Auth *Auth `protobuf:"bytes,2,opt,name=auth,proto3" json:"auth,omitempty"` Observability *Bootstrap_Observability `protobuf:"bytes,3,opt,name=observability,proto3" json:"observability,omitempty"` CredentialsService *v1.Credentials `protobuf:"bytes,4,opt,name=credentials_service,json=credentialsService,proto3" json:"credentials_service,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Deployment-level configuration for storage backend providers that + // need ambient knobs beyond what's stored per-CASBackend. Optional — + // omitting a sub-block keeps the corresponding provider unregistered. + BlobBackends *BlobBackends `protobuf:"bytes,5,opt,name=blob_backends,json=blobBackends,proto3" json:"blob_backends,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Bootstrap) Reset() { @@ -106,6 +110,13 @@ func (x *Bootstrap) GetCredentialsService() *v1.Credentials { return nil } +func (x *Bootstrap) GetBlobBackends() *BlobBackends { + if x != nil { + return x.BlobBackends + } + return nil +} + type Server struct { state protoimpl.MessageState `protogen:"open.v1"` // Regular HTTP endpoint @@ -169,6 +180,54 @@ func (x *Server) GetHttpMetrics() *Server_HTTP { return nil } +// BlobBackends mirrors the controlplane's `BlobBackends` block. Defined +// independently here so the artifact-cas binary doesn't depend on the +// controlplane's protobuf package. Keep field numbering in sync across +// both definitions. +type BlobBackends struct { + state protoimpl.MessageState `protogen:"open.v1"` + S3AccessPoint *BlobBackends_S3AccessPoint `protobuf:"bytes,1,opt,name=s3_access_point,json=s3AccessPoint,proto3" json:"s3_access_point,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlobBackends) Reset() { + *x = BlobBackends{} + mi := &file_conf_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlobBackends) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlobBackends) ProtoMessage() {} + +func (x *BlobBackends) ProtoReflect() protoreflect.Message { + mi := &file_conf_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BlobBackends.ProtoReflect.Descriptor instead. +func (*BlobBackends) Descriptor() ([]byte, []int) { + return file_conf_proto_rawDescGZIP(), []int{2} +} + +func (x *BlobBackends) GetS3AccessPoint() *BlobBackends_S3AccessPoint { + if x != nil { + return x.S3AccessPoint + } + return nil +} + type Auth struct { state protoimpl.MessageState `protogen:"open.v1"` // Public key used to verify the received JWT token @@ -184,7 +243,7 @@ type Auth struct { func (x *Auth) Reset() { *x = Auth{} - mi := &file_conf_proto_msgTypes[2] + mi := &file_conf_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -196,7 +255,7 @@ func (x *Auth) String() string { func (*Auth) ProtoMessage() {} func (x *Auth) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[2] + mi := &file_conf_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -209,7 +268,7 @@ func (x *Auth) ProtoReflect() protoreflect.Message { // Deprecated: Use Auth.ProtoReflect.Descriptor instead. func (*Auth) Descriptor() ([]byte, []int) { - return file_conf_proto_rawDescGZIP(), []int{2} + return file_conf_proto_rawDescGZIP(), []int{3} } // Deprecated: Marked as deprecated in conf.proto. @@ -237,7 +296,7 @@ type Bootstrap_Observability struct { func (x *Bootstrap_Observability) Reset() { *x = Bootstrap_Observability{} - mi := &file_conf_proto_msgTypes[3] + mi := &file_conf_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -249,7 +308,7 @@ func (x *Bootstrap_Observability) String() string { func (*Bootstrap_Observability) ProtoMessage() {} func (x *Bootstrap_Observability) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[3] + mi := &file_conf_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -290,7 +349,7 @@ type Bootstrap_Observability_Sentry struct { func (x *Bootstrap_Observability_Sentry) Reset() { *x = Bootstrap_Observability_Sentry{} - mi := &file_conf_proto_msgTypes[4] + mi := &file_conf_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -302,7 +361,7 @@ func (x *Bootstrap_Observability_Sentry) String() string { func (*Bootstrap_Observability_Sentry) ProtoMessage() {} func (x *Bootstrap_Observability_Sentry) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[4] + mi := &file_conf_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -349,7 +408,7 @@ type Bootstrap_Observability_Tracing struct { func (x *Bootstrap_Observability_Tracing) Reset() { *x = Bootstrap_Observability_Tracing{} - mi := &file_conf_proto_msgTypes[5] + mi := &file_conf_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -361,7 +420,7 @@ func (x *Bootstrap_Observability_Tracing) String() string { func (*Bootstrap_Observability_Tracing) ProtoMessage() {} func (x *Bootstrap_Observability_Tracing) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[5] + mi := &file_conf_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -414,7 +473,7 @@ type Server_CORS struct { func (x *Server_CORS) Reset() { *x = Server_CORS{} - mi := &file_conf_proto_msgTypes[6] + mi := &file_conf_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -426,7 +485,7 @@ func (x *Server_CORS) String() string { func (*Server_CORS) ProtoMessage() {} func (x *Server_CORS) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[6] + mi := &file_conf_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -462,7 +521,7 @@ type Server_HTTP struct { func (x *Server_HTTP) Reset() { *x = Server_HTTP{} - mi := &file_conf_proto_msgTypes[7] + mi := &file_conf_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -474,7 +533,7 @@ func (x *Server_HTTP) String() string { func (*Server_HTTP) ProtoMessage() {} func (x *Server_HTTP) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[7] + mi := &file_conf_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -529,7 +588,7 @@ type Server_TLS struct { func (x *Server_TLS) Reset() { *x = Server_TLS{} - mi := &file_conf_proto_msgTypes[8] + mi := &file_conf_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -541,7 +600,7 @@ func (x *Server_TLS) String() string { func (*Server_TLS) ProtoMessage() {} func (x *Server_TLS) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[8] + mi := &file_conf_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -583,7 +642,7 @@ type Server_GRPC struct { func (x *Server_GRPC) Reset() { *x = Server_GRPC{} - mi := &file_conf_proto_msgTypes[9] + mi := &file_conf_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -595,7 +654,7 @@ func (x *Server_GRPC) String() string { func (*Server_GRPC) ProtoMessage() {} func (x *Server_GRPC) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[9] + mi := &file_conf_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -639,17 +698,78 @@ func (x *Server_GRPC) GetTlsConfig() *Server_TLS { return nil } +type BlobBackends_S3AccessPoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + BaseRoleArn string `protobuf:"bytes,1,opt,name=base_role_arn,json=baseRoleArn,proto3" json:"base_role_arn,omitempty"` + Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` + SessionDuration *durationpb.Duration `protobuf:"bytes,3,opt,name=session_duration,json=sessionDuration,proto3" json:"session_duration,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlobBackends_S3AccessPoint) Reset() { + *x = BlobBackends_S3AccessPoint{} + mi := &file_conf_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlobBackends_S3AccessPoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlobBackends_S3AccessPoint) ProtoMessage() {} + +func (x *BlobBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { + mi := &file_conf_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BlobBackends_S3AccessPoint.ProtoReflect.Descriptor instead. +func (*BlobBackends_S3AccessPoint) Descriptor() ([]byte, []int) { + return file_conf_proto_rawDescGZIP(), []int{2, 0} +} + +func (x *BlobBackends_S3AccessPoint) GetBaseRoleArn() string { + if x != nil { + return x.BaseRoleArn + } + return "" +} + +func (x *BlobBackends_S3AccessPoint) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *BlobBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { + if x != nil { + return x.SessionDuration + } + return nil +} + var File_conf_proto protoreflect.FileDescriptor const file_conf_proto_rawDesc = "" + "\n" + "\n" + - "conf.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xb7\x04\n" + + "conf.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xeb\x04\n" + "\tBootstrap\x12\x1f\n" + "\x06server\x18\x01 \x01(\v2\a.ServerR\x06server\x12\x19\n" + "\x04auth\x18\x02 \x01(\v2\x05.AuthR\x04auth\x12>\n" + "\robservability\x18\x03 \x01(\v2\x18.Bootstrap.ObservabilityR\robservability\x12L\n" + - "\x13credentials_service\x18\x04 \x01(\v2\x1b.credentials.v1.CredentialsR\x12credentialsService\x1a\xdf\x02\n" + + "\x13credentials_service\x18\x04 \x01(\v2\x1b.credentials.v1.CredentialsR\x12credentialsService\x122\n" + + "\rblob_backends\x18\x05 \x01(\v2\r.BlobBackendsR\fblobBackends\x1a\xdf\x02\n" + "\rObservability\x127\n" + "\x06sentry\x18\x01 \x01(\v2\x1f.Bootstrap.Observability.SentryR\x06sentry\x12:\n" + "\atracing\x18\x02 \x01(\v2 .Bootstrap.Observability.TracingR\atracing\x1a<\n" + @@ -682,7 +802,13 @@ const file_conf_proto_rawDesc = "" + "\x04addr\x18\x02 \x01(\tR\x04addr\x123\n" + "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12*\n" + "\n" + - "tls_config\x18\x04 \x01(\v2\v.Server.TLSR\ttlsConfig\"t\n" + + "tls_config\x18\x04 \x01(\v2\v.Server.TLSR\ttlsConfig\"\xe7\x01\n" + + "\fBlobBackends\x12C\n" + + "\x0fs3_access_point\x18\x01 \x01(\v2\x1b.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\x91\x01\n" + + "\rS3AccessPoint\x12\"\n" + + "\rbase_role_arn\x18\x01 \x01(\tR\vbaseRoleArn\x12\x16\n" + + "\x06region\x18\x02 \x01(\tR\x06region\x12D\n" + + "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\"t\n" + "\x04Auth\x12D\n" + "\x1drobot_account_public_key_path\x18\x01 \x01(\tB\x02\x18\x01R\x19robotAccountPublicKeyPath\x12&\n" + "\x0fpublic_key_path\x18\x02 \x01(\tR\rpublicKeyPathBHZFgithub.com/chainloop-dev/chainloop/app/artifact-cas/internal/conf;confb\x06proto3" @@ -699,40 +825,45 @@ func file_conf_proto_rawDescGZIP() []byte { return file_conf_proto_rawDescData } -var file_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_conf_proto_goTypes = []any{ (*Bootstrap)(nil), // 0: Bootstrap (*Server)(nil), // 1: Server - (*Auth)(nil), // 2: Auth - (*Bootstrap_Observability)(nil), // 3: Bootstrap.Observability - (*Bootstrap_Observability_Sentry)(nil), // 4: Bootstrap.Observability.Sentry - (*Bootstrap_Observability_Tracing)(nil), // 5: Bootstrap.Observability.Tracing - (*Server_CORS)(nil), // 6: Server.CORS - (*Server_HTTP)(nil), // 7: Server.HTTP - (*Server_TLS)(nil), // 8: Server.TLS - (*Server_GRPC)(nil), // 9: Server.GRPC - (*v1.Credentials)(nil), // 10: credentials.v1.Credentials - (*durationpb.Duration)(nil), // 11: google.protobuf.Duration + (*BlobBackends)(nil), // 2: BlobBackends + (*Auth)(nil), // 3: Auth + (*Bootstrap_Observability)(nil), // 4: Bootstrap.Observability + (*Bootstrap_Observability_Sentry)(nil), // 5: Bootstrap.Observability.Sentry + (*Bootstrap_Observability_Tracing)(nil), // 6: Bootstrap.Observability.Tracing + (*Server_CORS)(nil), // 7: Server.CORS + (*Server_HTTP)(nil), // 8: Server.HTTP + (*Server_TLS)(nil), // 9: Server.TLS + (*Server_GRPC)(nil), // 10: Server.GRPC + (*BlobBackends_S3AccessPoint)(nil), // 11: BlobBackends.S3AccessPoint + (*v1.Credentials)(nil), // 12: credentials.v1.Credentials + (*durationpb.Duration)(nil), // 13: google.protobuf.Duration } var file_conf_proto_depIdxs = []int32{ 1, // 0: Bootstrap.server:type_name -> Server - 2, // 1: Bootstrap.auth:type_name -> Auth - 3, // 2: Bootstrap.observability:type_name -> Bootstrap.Observability - 10, // 3: Bootstrap.credentials_service:type_name -> credentials.v1.Credentials - 7, // 4: Server.http:type_name -> Server.HTTP - 9, // 5: Server.grpc:type_name -> Server.GRPC - 7, // 6: Server.http_metrics:type_name -> Server.HTTP - 4, // 7: Bootstrap.Observability.sentry:type_name -> Bootstrap.Observability.Sentry - 5, // 8: Bootstrap.Observability.tracing:type_name -> Bootstrap.Observability.Tracing - 11, // 9: Server.HTTP.timeout:type_name -> google.protobuf.Duration - 6, // 10: Server.HTTP.cors:type_name -> Server.CORS - 11, // 11: Server.GRPC.timeout:type_name -> google.protobuf.Duration - 8, // 12: Server.GRPC.tls_config:type_name -> Server.TLS - 13, // [13:13] is the sub-list for method output_type - 13, // [13:13] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 3, // 1: Bootstrap.auth:type_name -> Auth + 4, // 2: Bootstrap.observability:type_name -> Bootstrap.Observability + 12, // 3: Bootstrap.credentials_service:type_name -> credentials.v1.Credentials + 2, // 4: Bootstrap.blob_backends:type_name -> BlobBackends + 8, // 5: Server.http:type_name -> Server.HTTP + 10, // 6: Server.grpc:type_name -> Server.GRPC + 8, // 7: Server.http_metrics:type_name -> Server.HTTP + 11, // 8: BlobBackends.s3_access_point:type_name -> BlobBackends.S3AccessPoint + 5, // 9: Bootstrap.Observability.sentry:type_name -> Bootstrap.Observability.Sentry + 6, // 10: Bootstrap.Observability.tracing:type_name -> Bootstrap.Observability.Tracing + 13, // 11: Server.HTTP.timeout:type_name -> google.protobuf.Duration + 7, // 12: Server.HTTP.cors:type_name -> Server.CORS + 13, // 13: Server.GRPC.timeout:type_name -> google.protobuf.Duration + 9, // 14: Server.GRPC.tls_config:type_name -> Server.TLS + 13, // 15: BlobBackends.S3AccessPoint.session_duration:type_name -> google.protobuf.Duration + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_conf_proto_init() } @@ -740,14 +871,14 @@ func file_conf_proto_init() { if File_conf_proto != nil { return } - file_conf_proto_msgTypes[5].OneofWrappers = []any{} + file_conf_proto_msgTypes[6].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_conf_proto_rawDesc), len(file_conf_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, diff --git a/app/artifact-cas/internal/conf/conf.proto b/app/artifact-cas/internal/conf/conf.proto index 63c26a9bc..5b53f2cc4 100644 --- a/app/artifact-cas/internal/conf/conf.proto +++ b/app/artifact-cas/internal/conf/conf.proto @@ -25,6 +25,10 @@ message Bootstrap { Auth auth = 2; Observability observability = 3; credentials.v1.Credentials credentials_service = 4; + // Deployment-level configuration for storage backend providers that + // need ambient knobs beyond what's stored per-CASBackend. Optional — + // omitting a sub-block keeps the corresponding provider unregistered. + BlobBackends blob_backends = 5; message Observability { Sentry sentry = 1; @@ -79,6 +83,20 @@ message Server { HTTP http_metrics = 3; } +// BlobBackends mirrors the controlplane's `BlobBackends` block. Defined +// independently here so the artifact-cas binary doesn't depend on the +// controlplane's protobuf package. Keep field numbering in sync across +// both definitions. +message BlobBackends { + S3AccessPoint s3_access_point = 1; + + message S3AccessPoint { + string base_role_arn = 1; + string region = 2; + google.protobuf.Duration session_duration = 3; + } +} + message Auth { // Public key used to verify the received JWT token // This token in the context of chainloop has been crafted by the controlplane diff --git a/app/artifact-cas/internal/server/grpc_test.go b/app/artifact-cas/internal/server/grpc_test.go index 632efa583..4d4b9e767 100644 --- a/app/artifact-cas/internal/server/grpc_test.go +++ b/app/artifact-cas/internal/server/grpc_test.go @@ -97,7 +97,7 @@ func TestJWTAuthFunc(t *testing.T) { b, err := robotaccount.NewBuilder(opts...) require.NoError(t, err) - token, err := b.GenerateJWT("backend-type", "secret-id", tc.audience, robotaccount.Downloader, 0) + token, err := b.GenerateJWT("backend-type", "secret-id", tc.audience, robotaccount.Downloader, 0, "") require.NoError(t, err) // add bearer token to context diff --git a/app/artifact-cas/internal/service/bytestream.go b/app/artifact-cas/internal/service/bytestream.go index ba462bfd0..8af4611d2 100644 --- a/app/artifact-cas/internal/service/bytestream.go +++ b/app/artifact-cas/internal/service/bytestream.go @@ -80,7 +80,7 @@ func (s *ByteStreamService) Write(stream bytestream.ByteStream_WriteServer) erro return kerrors.BadRequest("resource name", err.Error()) } - storageBackend, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID) + ctx, storageBackend, err := s.loadBackendForClaims(ctx, info) if err != nil && kerrors.IsNotFound(err) { return err } else if err != nil { @@ -161,7 +161,7 @@ func (s *ByteStreamService) Read(req *bytestream.ReadRequest, stream bytestream. return kerrors.BadRequest("resource name", "empty resource name") } - backend, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID) + ctx, backend, err := s.loadBackendForClaims(ctx, info) if err != nil && kerrors.IsNotFound(err) { return err } else if err != nil { diff --git a/app/artifact-cas/internal/service/download.go b/app/artifact-cas/internal/service/download.go index 6100f8082..32b8ee01f 100644 --- a/app/artifact-cas/internal/service/download.go +++ b/app/artifact-cas/internal/service/download.go @@ -76,7 +76,7 @@ func (s *DownloadService) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - b, err := s.loadBackend(ctx, auth.BackendType, auth.StoredSecretID) + ctx, b, err := s.loadBackendForClaims(ctx, auth) if err != nil && kerrors.IsNotFound(err) { http.Error(w, "backend not found", http.StatusNotFound) return diff --git a/app/artifact-cas/internal/service/resource.go b/app/artifact-cas/internal/service/resource.go index 2c6924bd4..15fa50df7 100644 --- a/app/artifact-cas/internal/service/resource.go +++ b/app/artifact-cas/internal/service/resource.go @@ -48,7 +48,7 @@ func (s *ResourceService) Describe(ctx context.Context, req *v1.ResourceServiceD return nil, err } - b, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID) + ctx, b, err := s.loadBackendForClaims(ctx, info) if err != nil && errors.IsNotFound(err) { return nil, err } else if err != nil { diff --git a/app/artifact-cas/internal/service/service.go b/app/artifact-cas/internal/service/service.go index ed25510aa..c62f6b4dc 100644 --- a/app/artifact-cas/internal/service/service.go +++ b/app/artifact-cas/internal/service/service.go @@ -23,6 +23,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/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/servicelogger" kerrors "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/log" @@ -49,7 +50,11 @@ func (s *commonService) loadBackend(ctx context.Context, providerType, secretID s.log.Infow("msg", "selected provider", "provider", providerType) - // Retrieve the OCI backend from where to download the file + // Retrieve the backend from where to download the file. The context + // passed here is what the backend's STS-backed credentials provider + // will see; the caller is responsible for having enriched it with + // s3accesspoint.WithRequestingOrg when the request came in via an + // authenticated CAS JWT. See loadBackendForClaims. backend, err := p.FromCredentials(ctx, secretID) if err != nil { return nil, fmt.Errorf("failed to retrieve backend: %w", err) @@ -58,6 +63,28 @@ func (s *commonService) loadBackend(ctx context.Context, providerType, secretID return backend, nil } +// loadBackendForClaims is the request-scoped wrapper around loadBackend. +// It pulls the requesting-org UUID out of the CAS JWT claims and stamps +// it onto the context so providers that need per-tenant attribution +// (currently AWS-S3-ACCESS-POINT) can mint correctly-scoped sessions. +// +// The OrgID claim is empty for legacy tokens minted before this PR; in +// that case WithRequestingOrg sets an empty value and only managed +// providers (which fail closed) will reject the request — every existing +// provider ignores the key entirely, so non-managed flows stay unchanged. +// +// Returns the loaded backend together with the enriched context so the +// caller can reuse it for the subsequent Upload/Download calls. Callers +// MUST use the returned context, not the original one. +func (s *commonService) loadBackendForClaims(ctx context.Context, claims *casJWT.Claims) (context.Context, backend.UploaderDownloader, error) { + ctx = s3accesspoint.WithRequestingOrg(ctx, claims.OrgID) + b, err := s.loadBackend(ctx, claims.BackendType, claims.StoredSecretID) + if err != nil { + return ctx, nil, err + } + return ctx, b, nil +} + type NewOpt func(s *commonService) func WithLogger(logger log.Logger) NewOpt { diff --git a/app/controlplane/cmd/wire.go b/app/controlplane/cmd/wire.go index 5fd11a5ea..0728051d9 100644 --- a/app/controlplane/cmd/wire.go +++ b/app/controlplane/cmd/wire.go @@ -37,6 +37,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/policies" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/cache/attestationbundle" "github.com/chainloop-dev/chainloop/pkg/cache/policyevalbundle" @@ -55,11 +56,12 @@ func wireApp(context.Context, *conf.Bootstrap, credentials.ReaderWriter, log.Log data.ProviderSet, biz.ProviderSet, loader.LoadProviders, + newLoaderOptions, service.ProviderSet, wire.Bind(new(biz.CASClient), new(*biz.CASClientUseCase)), serviceOpts, wire.Value([]biz.CASClientOpts{}), - wire.FieldsOf(new(*conf.Bootstrap), "Server", "Auth", "Data", "CasServer", "ReferrerSharedIndex", "Onboarding", "PrometheusIntegration", "PolicyProviders", "NatsServer", "FederatedAuthentication", "OperationAuthorizationProvider"), + wire.FieldsOf(new(*conf.Bootstrap), "Server", "Auth", "Data", "CasServer", "ReferrerSharedIndex", "Onboarding", "PrometheusIntegration", "PolicyProviders", "NatsServer", "FederatedAuthentication", "OperationAuthorizationProvider", "BlobBackends"), wire.FieldsOf(new(*conf.Data), "Database"), dispatcher.New, authz.NewCasbinEnforcer, @@ -126,6 +128,25 @@ func serviceOpts(l log.Logger, authzUC *biz.AuthzUseCase, pUC *biz.ProjectUseCas } } +// newLoaderOptions builds the loader.Options struct from the deployment +// Bootstrap. When `blob_backends.s3_access_point` is absent (the common +// case for on-prem) S3AccessPoint stays nil and the provider is not +// registered, leaving the binary's behaviour identical to the pre-managed +// CAS world. +func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { + opts := &loader.Options{Logger: l} + if in == nil || in.GetS3AccessPoint() == nil { + return opts + } + ap := in.GetS3AccessPoint() + opts.S3AccessPoint = &s3accesspoint.Config{ + BaseRoleARN: ap.GetBaseRoleArn(), + Region: ap.GetRegion(), + SessionDuration: ap.GetSessionDuration().AsDuration(), + } + return opts +} + func newCASServerOptions(in *conf.Bootstrap_CASServer) *biz.CASServerDefaultOpts { if in == nil { return &biz.CASServerDefaultOpts{} diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index dfa28f207..eed3dcb3e 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -21,6 +21,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/policies" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/cache/attestationbundle" "github.com/chainloop-dev/chainloop/pkg/cache/policyevalbundle" @@ -64,7 +65,9 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr membershipRepo := data.NewMembershipRepo(dataData, groupRepo, logger) organizationRepo := data.NewOrganizationRepo(dataData, logger) casBackendRepo := data.NewCASBackendRepo(dataData, logger) - providers := loader.LoadProviders(readerWriter) + blobBackends := bootstrap.BlobBackends + options := newLoaderOptions(blobBackends, logger) + providers := loader.LoadProviders(readerWriter, options) bootstrap_CASServer := bootstrap.CasServer casServerDefaultOpts := newCASServerOptions(bootstrap_CASServer) bootstrap_NatsServer := bootstrap.NatsServer @@ -465,6 +468,25 @@ func serviceOpts(l log.Logger, authzUC *biz.AuthzUseCase, pUC *biz.ProjectUseCas return []service.NewOpt{service.WithLogger(l), service.WithEnforcer(authzUC), service.WithProjectUseCase(pUC), service.WithGroupUseCase(gUC)} } +// newLoaderOptions builds the loader.Options struct from the deployment +// Bootstrap. When `blob_backends.s3_access_point` is absent (the common +// case for on-prem) S3AccessPoint stays nil and the provider is not +// registered, leaving the binary's behaviour identical to the pre-managed +// CAS world. +func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { + opts := &loader.Options{Logger: l} + if in == nil || in.GetS3AccessPoint() == nil { + return opts + } + ap := in.GetS3AccessPoint() + opts.S3AccessPoint = &s3accesspoint.Config{ + BaseRoleARN: ap.GetBaseRoleArn(), + Region: ap.GetRegion(), + SessionDuration: ap.GetSessionDuration().AsDuration(), + } + return opts +} + func newCASServerOptions(in *conf.Bootstrap_CASServer) *biz.CASServerDefaultOpts { if in == nil { return &biz.CASServerDefaultOpts{} diff --git a/app/controlplane/configs/config.devel.yaml b/app/controlplane/configs/config.devel.yaml index 07976cfca..f6498c578 100644 --- a/app/controlplane/configs/config.devel.yaml +++ b/app/controlplane/configs/config.devel.yaml @@ -123,3 +123,15 @@ ui_dashboard_url: http://localhost:3000 attestations: skip_db_storage: true + +# Optional managed CAS provider (S3 Access Points). When set, the +# controlplane registers AWS-S3-ACCESS-POINT alongside the always-on +# providers (OCI, S3, AzureBlob). Leave commented out for on-prem +# deployments that don't use managed CAS — the pod's ambient AWS +# identity (IRSA / instance profile / AWS_* env vars) is what calls +# sts:AssumeRole on base_role_arn; no static credentials live here. +# blob_backends: +# s3_access_point: +# base_role_arn: arn:aws:iam::123456789012:role/chainloop-cas-tenant +# region: us-east-1 +# session_duration: 1h diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go index 038059240..ca64eb94f 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go @@ -83,7 +83,11 @@ type Bootstrap struct { // Optional external operation authorization provider OperationAuthorizationProvider *OperationAuthorizationProvider `protobuf:"bytes,20,opt,name=operation_authorization_provider,json=operationAuthorizationProvider,proto3" json:"operation_authorization_provider,omitempty"` // Attestation storage and processing options - Attestations *Attestations `protobuf:"bytes,21,opt,name=attestations,proto3" json:"attestations,omitempty"` + Attestations *Attestations `protobuf:"bytes,21,opt,name=attestations,proto3" json:"attestations,omitempty"` + // Deployment-level configuration for storage backend providers that + // need ambient knobs beyond what's stored per-CASBackend. Optional — + // omitting a sub-block keeps the corresponding provider unregistered. + BlobBackends *BlobBackends `protobuf:"bytes,22,opt,name=blob_backends,json=blobBackends,proto3" json:"blob_backends,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -266,6 +270,65 @@ func (x *Bootstrap) GetAttestations() *Attestations { return nil } +func (x *Bootstrap) GetBlobBackends() *BlobBackends { + if x != nil { + return x.BlobBackends + } + return nil +} + +// BlobBackends groups the additive, deployment-level config blocks for +// CAS storage backends. New providers append a nested message rather +// than adding top-level fields to Bootstrap, so the surface stays +// organised as more backends are added. +type BlobBackends struct { + state protoimpl.MessageState `protogen:"open.v1"` + // S3 Access Point provider — used by SaaS managed CAS to share one + // physical bucket across tenants. Authentication uses the pod's + // ambient AWS identity (IRSA / instance profile / env vars); no static + // credentials live in this block by design. + S3AccessPoint *BlobBackends_S3AccessPoint `protobuf:"bytes,1,opt,name=s3_access_point,json=s3AccessPoint,proto3" json:"s3_access_point,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlobBackends) Reset() { + *x = BlobBackends{} + mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlobBackends) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlobBackends) ProtoMessage() {} + +func (x *BlobBackends) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BlobBackends.ProtoReflect.Descriptor instead. +func (*BlobBackends) Descriptor() ([]byte, []int) { + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1} +} + +func (x *BlobBackends) GetS3AccessPoint() *BlobBackends_S3AccessPoint { + if x != nil { + return x.S3AccessPoint + } + return nil +} + type Attestations struct { state protoimpl.MessageState `protogen:"open.v1"` // When true, skip writing the attestation bundle to the per-run row in @@ -281,7 +344,7 @@ type Attestations struct { func (x *Attestations) Reset() { *x = Attestations{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -293,7 +356,7 @@ func (x *Attestations) String() string { func (*Attestations) ProtoMessage() {} func (x *Attestations) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -306,7 +369,7 @@ func (x *Attestations) ProtoReflect() protoreflect.Message { // Deprecated: Use Attestations.ProtoReflect.Descriptor instead. func (*Attestations) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{2} } func (x *Attestations) GetSkipDbStorage() bool { @@ -328,7 +391,7 @@ type OperationAuthorizationProvider struct { func (x *OperationAuthorizationProvider) Reset() { *x = OperationAuthorizationProvider{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -340,7 +403,7 @@ func (x *OperationAuthorizationProvider) String() string { func (*OperationAuthorizationProvider) ProtoMessage() {} func (x *OperationAuthorizationProvider) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -353,7 +416,7 @@ func (x *OperationAuthorizationProvider) ProtoReflect() protoreflect.Message { // Deprecated: Use OperationAuthorizationProvider.ProtoReflect.Descriptor instead. func (*OperationAuthorizationProvider) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{2} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{3} } func (x *OperationAuthorizationProvider) GetUrl() string { @@ -382,7 +445,7 @@ type FederatedAuthentication struct { func (x *FederatedAuthentication) Reset() { *x = FederatedAuthentication{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -394,7 +457,7 @@ func (x *FederatedAuthentication) String() string { func (*FederatedAuthentication) ProtoMessage() {} func (x *FederatedAuthentication) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -407,7 +470,7 @@ func (x *FederatedAuthentication) ProtoReflect() protoreflect.Message { // Deprecated: Use FederatedAuthentication.ProtoReflect.Descriptor instead. func (*FederatedAuthentication) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{3} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{4} } func (x *FederatedAuthentication) GetUrl() string { @@ -441,7 +504,7 @@ type PolicyProvider struct { func (x *PolicyProvider) Reset() { *x = PolicyProvider{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -453,7 +516,7 @@ func (x *PolicyProvider) String() string { func (*PolicyProvider) ProtoMessage() {} func (x *PolicyProvider) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -466,7 +529,7 @@ func (x *PolicyProvider) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyProvider.ProtoReflect.Descriptor instead. func (*PolicyProvider) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{4} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5} } func (x *PolicyProvider) GetName() string { @@ -514,7 +577,7 @@ type ReferrerSharedIndex struct { func (x *ReferrerSharedIndex) Reset() { *x = ReferrerSharedIndex{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -526,7 +589,7 @@ func (x *ReferrerSharedIndex) String() string { func (*ReferrerSharedIndex) ProtoMessage() {} func (x *ReferrerSharedIndex) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -539,7 +602,7 @@ func (x *ReferrerSharedIndex) ProtoReflect() protoreflect.Message { // Deprecated: Use ReferrerSharedIndex.ProtoReflect.Descriptor instead. func (*ReferrerSharedIndex) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6} } func (x *ReferrerSharedIndex) GetEnabled() bool { @@ -568,7 +631,7 @@ type Server struct { func (x *Server) Reset() { *x = Server{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -580,7 +643,7 @@ func (x *Server) String() string { func (*Server) ProtoMessage() {} func (x *Server) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -593,7 +656,7 @@ func (x *Server) ProtoReflect() protoreflect.Message { // Deprecated: Use Server.ProtoReflect.Descriptor instead. func (*Server) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7} } func (x *Server) GetHttp() *Server_HTTP { @@ -626,7 +689,7 @@ type Data struct { func (x *Data) Reset() { *x = Data{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -638,7 +701,7 @@ func (x *Data) String() string { func (*Data) ProtoMessage() {} func (x *Data) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -651,7 +714,7 @@ func (x *Data) ProtoReflect() protoreflect.Message { // Deprecated: Use Data.ProtoReflect.Descriptor instead. func (*Data) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8} } func (x *Data) GetDatabase() *Data_Database { @@ -676,7 +739,7 @@ type Auth struct { func (x *Auth) Reset() { *x = Auth{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -688,7 +751,7 @@ func (x *Auth) String() string { func (*Auth) ProtoMessage() {} func (x *Auth) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -701,7 +764,7 @@ func (x *Auth) ProtoReflect() protoreflect.Message { // Deprecated: Use Auth.ProtoReflect.Descriptor instead. func (*Auth) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9} } func (x *Auth) GetGeneratedJwsHmacSecret() string { @@ -753,7 +816,7 @@ type TSA struct { func (x *TSA) Reset() { *x = TSA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -765,7 +828,7 @@ func (x *TSA) String() string { func (*TSA) ProtoMessage() {} func (x *TSA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -778,7 +841,7 @@ func (x *TSA) ProtoReflect() protoreflect.Message { // Deprecated: Use TSA.ProtoReflect.Descriptor instead. func (*TSA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10} } func (x *TSA) GetUrl() string { @@ -819,7 +882,7 @@ type CA struct { func (x *CA) Reset() { *x = CA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -831,7 +894,7 @@ func (x *CA) String() string { func (*CA) ProtoMessage() {} func (x *CA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -844,7 +907,7 @@ func (x *CA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA.ProtoReflect.Descriptor instead. func (*CA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11} } func (x *CA) GetCa() isCA_Ca { @@ -906,7 +969,7 @@ type PrometheusIntegrationSpec struct { func (x *PrometheusIntegrationSpec) Reset() { *x = PrometheusIntegrationSpec{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -918,7 +981,7 @@ func (x *PrometheusIntegrationSpec) String() string { func (*PrometheusIntegrationSpec) ProtoMessage() {} func (x *PrometheusIntegrationSpec) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -931,7 +994,7 @@ func (x *PrometheusIntegrationSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use PrometheusIntegrationSpec.ProtoReflect.Descriptor instead. func (*PrometheusIntegrationSpec) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{12} } func (x *PrometheusIntegrationSpec) GetOrgName() string { @@ -951,7 +1014,7 @@ type Bootstrap_Observability struct { func (x *Bootstrap_Observability) Reset() { *x = Bootstrap_Observability{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -963,7 +1026,7 @@ func (x *Bootstrap_Observability) String() string { func (*Bootstrap_Observability) ProtoMessage() {} func (x *Bootstrap_Observability) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1013,7 +1076,7 @@ type Bootstrap_CASServer struct { func (x *Bootstrap_CASServer) Reset() { *x = Bootstrap_CASServer{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1025,7 +1088,7 @@ func (x *Bootstrap_CASServer) String() string { func (*Bootstrap_CASServer) ProtoMessage() {} func (x *Bootstrap_CASServer) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1086,7 +1149,7 @@ type Bootstrap_NatsServer struct { func (x *Bootstrap_NatsServer) Reset() { *x = Bootstrap_NatsServer{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1098,7 +1161,7 @@ func (x *Bootstrap_NatsServer) String() string { func (*Bootstrap_NatsServer) ProtoMessage() {} func (x *Bootstrap_NatsServer) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1165,7 +1228,7 @@ type Bootstrap_Observability_Sentry struct { func (x *Bootstrap_Observability_Sentry) Reset() { *x = Bootstrap_Observability_Sentry{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1177,7 +1240,7 @@ func (x *Bootstrap_Observability_Sentry) String() string { func (*Bootstrap_Observability_Sentry) ProtoMessage() {} func (x *Bootstrap_Observability_Sentry) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1224,7 +1287,7 @@ type Bootstrap_Observability_Tracing struct { func (x *Bootstrap_Observability_Tracing) Reset() { *x = Bootstrap_Observability_Tracing{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1236,7 +1299,7 @@ func (x *Bootstrap_Observability_Tracing) String() string { func (*Bootstrap_Observability_Tracing) ProtoMessage() {} func (x *Bootstrap_Observability_Tracing) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1280,6 +1343,72 @@ func (x *Bootstrap_Observability_Tracing) GetSamplingRatio() float64 { return 0 } +type BlobBackends_S3AccessPoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + // IAM role the controlplane / artifact-cas pod assumes per request + // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every + // access point in the account. + BaseRoleArn string `protobuf:"bytes,1,opt,name=base_role_arn,json=baseRoleArn,proto3" json:"base_role_arn,omitempty"` + // Default AWS region for the underlying bucket and access points. + // Individual managed CASBackend rows can override per-tenant. + Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` + // STS token lifetime. Defaults to 1h when unset. + SessionDuration *durationpb.Duration `protobuf:"bytes,3,opt,name=session_duration,json=sessionDuration,proto3" json:"session_duration,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlobBackends_S3AccessPoint) Reset() { + *x = BlobBackends_S3AccessPoint{} + mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlobBackends_S3AccessPoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlobBackends_S3AccessPoint) ProtoMessage() {} + +func (x *BlobBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BlobBackends_S3AccessPoint.ProtoReflect.Descriptor instead. +func (*BlobBackends_S3AccessPoint) Descriptor() ([]byte, []int) { + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1, 0} +} + +func (x *BlobBackends_S3AccessPoint) GetBaseRoleArn() string { + if x != nil { + return x.BaseRoleArn + } + return "" +} + +func (x *BlobBackends_S3AccessPoint) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *BlobBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { + if x != nil { + return x.SessionDuration + } + return nil +} + type Server_HTTP struct { state protoimpl.MessageState `protogen:"open.v1"` Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` @@ -1294,7 +1423,7 @@ type Server_HTTP struct { func (x *Server_HTTP) Reset() { *x = Server_HTTP{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1306,7 +1435,7 @@ func (x *Server_HTTP) String() string { func (*Server_HTTP) ProtoMessage() {} func (x *Server_HTTP) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1319,7 +1448,7 @@ func (x *Server_HTTP) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_HTTP.ProtoReflect.Descriptor instead. func (*Server_HTTP) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 0} } func (x *Server_HTTP) GetNetwork() string { @@ -1361,7 +1490,7 @@ type Server_TLS struct { func (x *Server_TLS) Reset() { *x = Server_TLS{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1373,7 +1502,7 @@ func (x *Server_TLS) String() string { func (*Server_TLS) ProtoMessage() {} func (x *Server_TLS) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1386,7 +1515,7 @@ func (x *Server_TLS) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_TLS.ProtoReflect.Descriptor instead. func (*Server_TLS) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 1} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 1} } func (x *Server_TLS) GetCertificate() string { @@ -1418,7 +1547,7 @@ type Server_GRPC struct { func (x *Server_GRPC) Reset() { *x = Server_GRPC{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1430,7 +1559,7 @@ func (x *Server_GRPC) String() string { func (*Server_GRPC) ProtoMessage() {} func (x *Server_GRPC) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1443,7 +1572,7 @@ func (x *Server_GRPC) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_GRPC.ProtoReflect.Descriptor instead. func (*Server_GRPC) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 2} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 2} } func (x *Server_GRPC) GetNetwork() string { @@ -1497,7 +1626,7 @@ type Data_Database struct { func (x *Data_Database) Reset() { *x = Data_Database{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1509,7 +1638,7 @@ func (x *Data_Database) String() string { func (*Data_Database) ProtoMessage() {} func (x *Data_Database) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1522,7 +1651,7 @@ func (x *Data_Database) ProtoReflect() protoreflect.Message { // Deprecated: Use Data_Database.ProtoReflect.Descriptor instead. func (*Data_Database) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8, 0} } func (x *Data_Database) GetDriver() string { @@ -1574,7 +1703,7 @@ type Auth_OIDC struct { func (x *Auth_OIDC) Reset() { *x = Auth_OIDC{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1586,7 +1715,7 @@ func (x *Auth_OIDC) String() string { func (*Auth_OIDC) ProtoMessage() {} func (x *Auth_OIDC) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1599,7 +1728,7 @@ func (x *Auth_OIDC) ProtoReflect() protoreflect.Message { // Deprecated: Use Auth_OIDC.ProtoReflect.Descriptor instead. func (*Auth_OIDC) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9, 0} } func (x *Auth_OIDC) GetDomain() string { @@ -1641,7 +1770,7 @@ type CA_FileCA struct { func (x *CA_FileCA) Reset() { *x = CA_FileCA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1653,7 +1782,7 @@ func (x *CA_FileCA) String() string { func (*CA_FileCA) ProtoMessage() {} func (x *CA_FileCA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1666,7 +1795,7 @@ func (x *CA_FileCA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA_FileCA.ProtoReflect.Descriptor instead. func (*CA_FileCA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11, 0} } func (x *CA_FileCA) GetCertPath() string { @@ -1707,7 +1836,7 @@ type CA_EJBCA struct { func (x *CA_EJBCA) Reset() { *x = CA_EJBCA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1719,7 +1848,7 @@ func (x *CA_EJBCA) String() string { func (*CA_EJBCA) ProtoMessage() {} func (x *CA_EJBCA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1732,7 +1861,7 @@ func (x *CA_EJBCA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA_EJBCA.ProtoReflect.Descriptor instead. func (*CA_EJBCA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10, 1} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11, 1} } func (x *CA_EJBCA) GetServerUrl() string { @@ -1788,7 +1917,7 @@ var File_controlplane_config_v1_conf_proto protoreflect.FileDescriptor const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\n" + - "!controlplane/config/v1/conf.proto\x12\x16controlplane.config.v1\x1a\x1bbuf/validate/validate.proto\x1a#controlplane/config/v1/config.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xf5\x11\n" + + "!controlplane/config/v1/conf.proto\x12\x16controlplane.config.v1\x1a\x1bbuf/validate/validate.proto\x1a#controlplane/config/v1/config.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xc0\x12\n" + "\tBootstrap\x126\n" + "\x06server\x18\x01 \x01(\v2\x1e.controlplane.config.v1.ServerR\x06server\x120\n" + "\x04data\x18\x02 \x01(\v2\x1c.controlplane.config.v1.DataR\x04data\x120\n" + @@ -1816,7 +1945,8 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x15restrict_org_creation\x18\x12 \x01(\bR\x13restrictOrgCreation\x12(\n" + "\x10ui_dashboard_url\x18\x13 \x01(\tR\x0euiDashboardUrl\x12\x80\x01\n" + " operation_authorization_provider\x18\x14 \x01(\v26.controlplane.config.v1.OperationAuthorizationProviderR\x1eoperationAuthorizationProvider\x12H\n" + - "\fattestations\x18\x15 \x01(\v2$.controlplane.config.v1.AttestationsR\fattestations\x1a\x8d\x03\n" + + "\fattestations\x18\x15 \x01(\v2$.controlplane.config.v1.AttestationsR\fattestations\x12I\n" + + "\rblob_backends\x18\x16 \x01(\v2$.controlplane.config.v1.BlobBackendsR\fblobBackends\x1a\x8d\x03\n" + "\rObservability\x12N\n" + "\x06sentry\x18\x01 \x01(\v26.controlplane.config.v1.Bootstrap.Observability.SentryR\x06sentry\x12Q\n" + "\atracing\x18\x02 \x01(\v27.controlplane.config.v1.Bootstrap.Observability.TracingR\atracing\x1a<\n" + @@ -1839,7 +1969,13 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x03uri\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x03uri\x12\x1f\n" + "\x05token\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x05token\x12\x1a\n" + "\breplicas\x18\x03 \x01(\x05R\breplicasB\x10\n" + - "\x0eauthentication\"6\n" + + "\x0eauthentication\"\x90\x02\n" + + "\fBlobBackends\x12Z\n" + + "\x0fs3_access_point\x18\x01 \x01(\v22.controlplane.config.v1.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\xa3\x01\n" + + "\rS3AccessPoint\x12+\n" + + "\rbase_role_arn\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\vbaseRoleArn\x12\x1f\n" + + "\x06region\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06region\x12D\n" + + "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\"6\n" + "\fAttestations\x12&\n" + "\x0fskip_db_storage\x18\x01 \x01(\bR\rskipDbStorage\"V\n" + "\x1eOperationAuthorizationProvider\x12\x1a\n" + @@ -1935,75 +2071,80 @@ func file_controlplane_config_v1_conf_proto_rawDescGZIP() []byte { return file_controlplane_config_v1_conf_proto_rawDescData } -var file_controlplane_config_v1_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_controlplane_config_v1_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_controlplane_config_v1_conf_proto_goTypes = []any{ (*Bootstrap)(nil), // 0: controlplane.config.v1.Bootstrap - (*Attestations)(nil), // 1: controlplane.config.v1.Attestations - (*OperationAuthorizationProvider)(nil), // 2: controlplane.config.v1.OperationAuthorizationProvider - (*FederatedAuthentication)(nil), // 3: controlplane.config.v1.FederatedAuthentication - (*PolicyProvider)(nil), // 4: controlplane.config.v1.PolicyProvider - (*ReferrerSharedIndex)(nil), // 5: controlplane.config.v1.ReferrerSharedIndex - (*Server)(nil), // 6: controlplane.config.v1.Server - (*Data)(nil), // 7: controlplane.config.v1.Data - (*Auth)(nil), // 8: controlplane.config.v1.Auth - (*TSA)(nil), // 9: controlplane.config.v1.TSA - (*CA)(nil), // 10: controlplane.config.v1.CA - (*PrometheusIntegrationSpec)(nil), // 11: controlplane.config.v1.PrometheusIntegrationSpec - (*Bootstrap_Observability)(nil), // 12: controlplane.config.v1.Bootstrap.Observability - (*Bootstrap_CASServer)(nil), // 13: controlplane.config.v1.Bootstrap.CASServer - (*Bootstrap_NatsServer)(nil), // 14: controlplane.config.v1.Bootstrap.NatsServer - (*Bootstrap_Observability_Sentry)(nil), // 15: controlplane.config.v1.Bootstrap.Observability.Sentry - (*Bootstrap_Observability_Tracing)(nil), // 16: controlplane.config.v1.Bootstrap.Observability.Tracing - (*Server_HTTP)(nil), // 17: controlplane.config.v1.Server.HTTP - (*Server_TLS)(nil), // 18: controlplane.config.v1.Server.TLS - (*Server_GRPC)(nil), // 19: controlplane.config.v1.Server.GRPC - (*Data_Database)(nil), // 20: controlplane.config.v1.Data.Database - (*Auth_OIDC)(nil), // 21: controlplane.config.v1.Auth.OIDC - (*CA_FileCA)(nil), // 22: controlplane.config.v1.CA.FileCA - (*CA_EJBCA)(nil), // 23: controlplane.config.v1.CA.EJBCA - (*v1.Credentials)(nil), // 24: credentials.v1.Credentials - (*v11.OnboardingSpec)(nil), // 25: controlplane.config.v1.OnboardingSpec - (*v11.AllowList)(nil), // 26: controlplane.config.v1.AllowList - (*durationpb.Duration)(nil), // 27: google.protobuf.Duration + (*BlobBackends)(nil), // 1: controlplane.config.v1.BlobBackends + (*Attestations)(nil), // 2: controlplane.config.v1.Attestations + (*OperationAuthorizationProvider)(nil), // 3: controlplane.config.v1.OperationAuthorizationProvider + (*FederatedAuthentication)(nil), // 4: controlplane.config.v1.FederatedAuthentication + (*PolicyProvider)(nil), // 5: controlplane.config.v1.PolicyProvider + (*ReferrerSharedIndex)(nil), // 6: controlplane.config.v1.ReferrerSharedIndex + (*Server)(nil), // 7: controlplane.config.v1.Server + (*Data)(nil), // 8: controlplane.config.v1.Data + (*Auth)(nil), // 9: controlplane.config.v1.Auth + (*TSA)(nil), // 10: controlplane.config.v1.TSA + (*CA)(nil), // 11: controlplane.config.v1.CA + (*PrometheusIntegrationSpec)(nil), // 12: controlplane.config.v1.PrometheusIntegrationSpec + (*Bootstrap_Observability)(nil), // 13: controlplane.config.v1.Bootstrap.Observability + (*Bootstrap_CASServer)(nil), // 14: controlplane.config.v1.Bootstrap.CASServer + (*Bootstrap_NatsServer)(nil), // 15: controlplane.config.v1.Bootstrap.NatsServer + (*Bootstrap_Observability_Sentry)(nil), // 16: controlplane.config.v1.Bootstrap.Observability.Sentry + (*Bootstrap_Observability_Tracing)(nil), // 17: controlplane.config.v1.Bootstrap.Observability.Tracing + (*BlobBackends_S3AccessPoint)(nil), // 18: controlplane.config.v1.BlobBackends.S3AccessPoint + (*Server_HTTP)(nil), // 19: controlplane.config.v1.Server.HTTP + (*Server_TLS)(nil), // 20: controlplane.config.v1.Server.TLS + (*Server_GRPC)(nil), // 21: controlplane.config.v1.Server.GRPC + (*Data_Database)(nil), // 22: controlplane.config.v1.Data.Database + (*Auth_OIDC)(nil), // 23: controlplane.config.v1.Auth.OIDC + (*CA_FileCA)(nil), // 24: controlplane.config.v1.CA.FileCA + (*CA_EJBCA)(nil), // 25: controlplane.config.v1.CA.EJBCA + (*v1.Credentials)(nil), // 26: credentials.v1.Credentials + (*v11.OnboardingSpec)(nil), // 27: controlplane.config.v1.OnboardingSpec + (*v11.AllowList)(nil), // 28: controlplane.config.v1.AllowList + (*durationpb.Duration)(nil), // 29: google.protobuf.Duration } var file_controlplane_config_v1_conf_proto_depIdxs = []int32{ - 6, // 0: controlplane.config.v1.Bootstrap.server:type_name -> controlplane.config.v1.Server - 7, // 1: controlplane.config.v1.Bootstrap.data:type_name -> controlplane.config.v1.Data - 8, // 2: controlplane.config.v1.Bootstrap.auth:type_name -> controlplane.config.v1.Auth - 12, // 3: controlplane.config.v1.Bootstrap.observability:type_name -> controlplane.config.v1.Bootstrap.Observability - 24, // 4: controlplane.config.v1.Bootstrap.credentials_service:type_name -> credentials.v1.Credentials - 13, // 5: controlplane.config.v1.Bootstrap.cas_server:type_name -> controlplane.config.v1.Bootstrap.CASServer - 5, // 6: controlplane.config.v1.Bootstrap.referrer_shared_index:type_name -> controlplane.config.v1.ReferrerSharedIndex - 10, // 7: controlplane.config.v1.Bootstrap.certificate_authority:type_name -> controlplane.config.v1.CA - 10, // 8: controlplane.config.v1.Bootstrap.certificate_authorities:type_name -> controlplane.config.v1.CA - 9, // 9: controlplane.config.v1.Bootstrap.timestamp_authorities:type_name -> controlplane.config.v1.TSA - 25, // 10: controlplane.config.v1.Bootstrap.onboarding:type_name -> controlplane.config.v1.OnboardingSpec - 11, // 11: controlplane.config.v1.Bootstrap.prometheus_integration:type_name -> controlplane.config.v1.PrometheusIntegrationSpec - 4, // 12: controlplane.config.v1.Bootstrap.policy_providers:type_name -> controlplane.config.v1.PolicyProvider - 14, // 13: controlplane.config.v1.Bootstrap.nats_server:type_name -> controlplane.config.v1.Bootstrap.NatsServer - 3, // 14: controlplane.config.v1.Bootstrap.federated_authentication:type_name -> controlplane.config.v1.FederatedAuthentication - 2, // 15: controlplane.config.v1.Bootstrap.operation_authorization_provider:type_name -> controlplane.config.v1.OperationAuthorizationProvider - 1, // 16: controlplane.config.v1.Bootstrap.attestations:type_name -> controlplane.config.v1.Attestations - 17, // 17: controlplane.config.v1.Server.http:type_name -> controlplane.config.v1.Server.HTTP - 19, // 18: controlplane.config.v1.Server.grpc:type_name -> controlplane.config.v1.Server.GRPC - 17, // 19: controlplane.config.v1.Server.http_metrics:type_name -> controlplane.config.v1.Server.HTTP - 20, // 20: controlplane.config.v1.Data.database:type_name -> controlplane.config.v1.Data.Database - 26, // 21: controlplane.config.v1.Auth.allow_list:type_name -> controlplane.config.v1.AllowList - 21, // 22: controlplane.config.v1.Auth.oidc:type_name -> controlplane.config.v1.Auth.OIDC - 22, // 23: controlplane.config.v1.CA.file_ca:type_name -> controlplane.config.v1.CA.FileCA - 23, // 24: controlplane.config.v1.CA.ejbca_ca:type_name -> controlplane.config.v1.CA.EJBCA - 15, // 25: controlplane.config.v1.Bootstrap.Observability.sentry:type_name -> controlplane.config.v1.Bootstrap.Observability.Sentry - 16, // 26: controlplane.config.v1.Bootstrap.Observability.tracing:type_name -> controlplane.config.v1.Bootstrap.Observability.Tracing - 19, // 27: controlplane.config.v1.Bootstrap.CASServer.grpc:type_name -> controlplane.config.v1.Server.GRPC - 27, // 28: controlplane.config.v1.Server.HTTP.timeout:type_name -> google.protobuf.Duration - 27, // 29: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration - 18, // 30: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS - 27, // 31: controlplane.config.v1.Data.Database.max_conn_idle_time:type_name -> google.protobuf.Duration - 32, // [32:32] is the sub-list for method output_type - 32, // [32:32] is the sub-list for method input_type - 32, // [32:32] is the sub-list for extension type_name - 32, // [32:32] is the sub-list for extension extendee - 0, // [0:32] is the sub-list for field type_name + 7, // 0: controlplane.config.v1.Bootstrap.server:type_name -> controlplane.config.v1.Server + 8, // 1: controlplane.config.v1.Bootstrap.data:type_name -> controlplane.config.v1.Data + 9, // 2: controlplane.config.v1.Bootstrap.auth:type_name -> controlplane.config.v1.Auth + 13, // 3: controlplane.config.v1.Bootstrap.observability:type_name -> controlplane.config.v1.Bootstrap.Observability + 26, // 4: controlplane.config.v1.Bootstrap.credentials_service:type_name -> credentials.v1.Credentials + 14, // 5: controlplane.config.v1.Bootstrap.cas_server:type_name -> controlplane.config.v1.Bootstrap.CASServer + 6, // 6: controlplane.config.v1.Bootstrap.referrer_shared_index:type_name -> controlplane.config.v1.ReferrerSharedIndex + 11, // 7: controlplane.config.v1.Bootstrap.certificate_authority:type_name -> controlplane.config.v1.CA + 11, // 8: controlplane.config.v1.Bootstrap.certificate_authorities:type_name -> controlplane.config.v1.CA + 10, // 9: controlplane.config.v1.Bootstrap.timestamp_authorities:type_name -> controlplane.config.v1.TSA + 27, // 10: controlplane.config.v1.Bootstrap.onboarding:type_name -> controlplane.config.v1.OnboardingSpec + 12, // 11: controlplane.config.v1.Bootstrap.prometheus_integration:type_name -> controlplane.config.v1.PrometheusIntegrationSpec + 5, // 12: controlplane.config.v1.Bootstrap.policy_providers:type_name -> controlplane.config.v1.PolicyProvider + 15, // 13: controlplane.config.v1.Bootstrap.nats_server:type_name -> controlplane.config.v1.Bootstrap.NatsServer + 4, // 14: controlplane.config.v1.Bootstrap.federated_authentication:type_name -> controlplane.config.v1.FederatedAuthentication + 3, // 15: controlplane.config.v1.Bootstrap.operation_authorization_provider:type_name -> controlplane.config.v1.OperationAuthorizationProvider + 2, // 16: controlplane.config.v1.Bootstrap.attestations:type_name -> controlplane.config.v1.Attestations + 1, // 17: controlplane.config.v1.Bootstrap.blob_backends:type_name -> controlplane.config.v1.BlobBackends + 18, // 18: controlplane.config.v1.BlobBackends.s3_access_point:type_name -> controlplane.config.v1.BlobBackends.S3AccessPoint + 19, // 19: controlplane.config.v1.Server.http:type_name -> controlplane.config.v1.Server.HTTP + 21, // 20: controlplane.config.v1.Server.grpc:type_name -> controlplane.config.v1.Server.GRPC + 19, // 21: controlplane.config.v1.Server.http_metrics:type_name -> controlplane.config.v1.Server.HTTP + 22, // 22: controlplane.config.v1.Data.database:type_name -> controlplane.config.v1.Data.Database + 28, // 23: controlplane.config.v1.Auth.allow_list:type_name -> controlplane.config.v1.AllowList + 23, // 24: controlplane.config.v1.Auth.oidc:type_name -> controlplane.config.v1.Auth.OIDC + 24, // 25: controlplane.config.v1.CA.file_ca:type_name -> controlplane.config.v1.CA.FileCA + 25, // 26: controlplane.config.v1.CA.ejbca_ca:type_name -> controlplane.config.v1.CA.EJBCA + 16, // 27: controlplane.config.v1.Bootstrap.Observability.sentry:type_name -> controlplane.config.v1.Bootstrap.Observability.Sentry + 17, // 28: controlplane.config.v1.Bootstrap.Observability.tracing:type_name -> controlplane.config.v1.Bootstrap.Observability.Tracing + 21, // 29: controlplane.config.v1.Bootstrap.CASServer.grpc:type_name -> controlplane.config.v1.Server.GRPC + 29, // 30: controlplane.config.v1.BlobBackends.S3AccessPoint.session_duration:type_name -> google.protobuf.Duration + 29, // 31: controlplane.config.v1.Server.HTTP.timeout:type_name -> google.protobuf.Duration + 29, // 32: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration + 20, // 33: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS + 29, // 34: controlplane.config.v1.Data.Database.max_conn_idle_time:type_name -> google.protobuf.Duration + 35, // [35:35] is the sub-list for method output_type + 35, // [35:35] is the sub-list for method input_type + 35, // [35:35] is the sub-list for extension type_name + 35, // [35:35] is the sub-list for extension extendee + 0, // [0:35] is the sub-list for field type_name } func init() { file_controlplane_config_v1_conf_proto_init() } @@ -2011,21 +2152,21 @@ func file_controlplane_config_v1_conf_proto_init() { if File_controlplane_config_v1_conf_proto != nil { return } - file_controlplane_config_v1_conf_proto_msgTypes[10].OneofWrappers = []any{ + file_controlplane_config_v1_conf_proto_msgTypes[11].OneofWrappers = []any{ (*CA_FileCa)(nil), (*CA_EjbcaCa)(nil), } - file_controlplane_config_v1_conf_proto_msgTypes[14].OneofWrappers = []any{ + file_controlplane_config_v1_conf_proto_msgTypes[15].OneofWrappers = []any{ (*Bootstrap_NatsServer_Token)(nil), } - file_controlplane_config_v1_conf_proto_msgTypes[16].OneofWrappers = []any{} + file_controlplane_config_v1_conf_proto_msgTypes[17].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_config_v1_conf_proto_rawDesc), len(file_controlplane_config_v1_conf_proto_rawDesc)), NumEnums: 0, - NumMessages: 24, + NumMessages: 26, NumExtensions: 0, NumServices: 0, }, diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto index 4b5f7a4ba..5841a8d38 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto @@ -126,6 +126,35 @@ message Bootstrap { // Attestation storage and processing options Attestations attestations = 21; + + // Deployment-level configuration for storage backend providers that + // need ambient knobs beyond what's stored per-CASBackend. Optional — + // omitting a sub-block keeps the corresponding provider unregistered. + BlobBackends blob_backends = 22; +} + +// BlobBackends groups the additive, deployment-level config blocks for +// CAS storage backends. New providers append a nested message rather +// than adding top-level fields to Bootstrap, so the surface stays +// organised as more backends are added. +message BlobBackends { + // S3 Access Point provider — used by SaaS managed CAS to share one + // physical bucket across tenants. Authentication uses the pod's + // ambient AWS identity (IRSA / instance profile / env vars); no static + // credentials live in this block by design. + S3AccessPoint s3_access_point = 1; + + message S3AccessPoint { + // IAM role the controlplane / artifact-cas pod assumes per request + // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every + // access point in the account. + string base_role_arn = 1 [(buf.validate.field).string.min_len = 1]; + // Default AWS region for the underlying bucket and access points. + // Individual managed CASBackend rows can override per-tenant. + string region = 2 [(buf.validate.field).string.min_len = 1]; + // STS token lifetime. Defaults to 1h when unset. + google.protobuf.Duration session_duration = 3; + } } message Attestations { diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index 54d318618..2eb29fee5 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -494,7 +494,7 @@ func (s *AttestationService) GetUploadCreds(ctx context.Context, req *cpAPI.Atte // Return the backend information and associated credentials (if applicable) resp := &cpAPI.AttestationServiceGetUploadCredsResponse_Result{Backend: bizCASBackendToPb(backend)} if backend.SecretName != "" { - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID.String()} t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/cascredential.go b/app/controlplane/internal/service/cascredential.go index 9729100fa..436122e5a 100644 --- a/app/controlplane/internal/service/cascredential.go +++ b/app/controlplane/internal/service/cascredential.go @@ -149,7 +149,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} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID.String()} t, err := s.casUC.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/casredirect.go b/app/controlplane/internal/service/casredirect.go index 431c5a6a8..74d6ec437 100644 --- a/app/controlplane/internal/service/casredirect.go +++ b/app/controlplane/internal/service/casredirect.go @@ -126,7 +126,7 @@ func (s *CASRedirectService) GetDownloadURL(ctx context.Context, req *pb.GetDown // 2- add authentication token to the query params ?t=[token] if backend.SecretName != "" { - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID.String()} t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/pkg/biz/casbackend.go b/app/controlplane/pkg/biz/casbackend.go index 89ef89132..007fa79f7 100644 --- a/app/controlplane/pkg/biz/casbackend.go +++ b/app/controlplane/pkg/biz/casbackend.go @@ -29,6 +29,7 @@ import ( "github.com/chainloop-dev/chainloop/pkg/blobmanager/azureblob" "github.com/chainloop-dev/chainloop/pkg/blobmanager/oci" "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/credentials" "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/chainloop-dev/chainloop/pkg/servicelogger" @@ -827,7 +828,7 @@ func (uc *CASBackendUseCase) PerformValidation(ctx context.Context, id string) e // Implements https://pkg.go.dev/entgo.io/ent/schema/field#EnumValues func (CASBackendProvider) Values() (kinds []string) { - for _, s := range []CASBackendProvider{azureblob.ProviderID, oci.ProviderID, CASBackendInline, s3.ProviderID} { + for _, s := range []CASBackendProvider{azureblob.ProviderID, oci.ProviderID, CASBackendInline, s3.ProviderID, s3accesspoint.ProviderID} { kinds = append(kinds, string(s)) } diff --git a/app/controlplane/pkg/biz/cascredentials.go b/app/controlplane/pkg/biz/cascredentials.go index 6e9b3bef2..73374a53d 100644 --- a/app/controlplane/pkg/biz/cascredentials.go +++ b/app/controlplane/pkg/biz/cascredentials.go @@ -48,8 +48,12 @@ type CASCredsOpts struct { SecretPath string // path to for example the OCI secret in the vault Role robotaccount.Role MaxBytes int64 + // OrgID is the requesting organization's UUID. Required for managed + // backends (e.g. AWS-S3-ACCESS-POINT) that need to scope per-tenant + // STS sessions; optional for the others (OCI, S3, AzureBlob). + OrgID string } func (uc *CASCredentialsUseCase) GenerateTemporaryCredentials(backendRef *CASCredsOpts) (string, error) { - return uc.jwtBuilder.GenerateJWT(backendRef.BackendType, backendRef.SecretPath, jwt.CASAudience, backendRef.Role, backendRef.MaxBytes) + return uc.jwtBuilder.GenerateJWT(backendRef.BackendType, backendRef.SecretPath, jwt.CASAudience, backendRef.Role, backendRef.MaxBytes, backendRef.OrgID) } diff --git a/app/controlplane/pkg/data/ent/casbackend/casbackend.go b/app/controlplane/pkg/data/ent/casbackend/casbackend.go index 8fe9e8c68..f2f465b3d 100644 --- a/app/controlplane/pkg/data/ent/casbackend/casbackend.go +++ b/app/controlplane/pkg/data/ent/casbackend/casbackend.go @@ -136,7 +136,7 @@ var ( // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr biz.CASBackendProvider) error { switch pr { - case "AzureBlob", "OCI", "INLINE", "AWS-S3": + case "AzureBlob", "OCI", "INLINE", "AWS-S3", "AWS-S3-ACCESS-POINT": return nil default: return fmt.Errorf("casbackend: invalid enum value for provider field: %q", pr) diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 7f1870ae0..85e4bc67c 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -94,7 +94,7 @@ var ( {Name: "id", Type: field.TypeUUID, Unique: true}, {Name: "location", Type: field.TypeString}, {Name: "name", Type: field.TypeString}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"AzureBlob", "OCI", "INLINE", "AWS-S3"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"AzureBlob", "OCI", "INLINE", "AWS-S3", "AWS-S3-ACCESS-POINT"}}, {Name: "description", Type: field.TypeString, Nullable: true}, {Name: "secret_name", Type: field.TypeString}, {Name: "created_at", Type: field.TypeTime, Default: "CURRENT_TIMESTAMP"}, diff --git a/go.mod b/go.mod index 251963644..815c2f62f 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( entgo.io/ent v0.14.6-0.20251003170342-01063ef6395c github.com/adrg/xdg v0.4.0 github.com/aws/aws-sdk-go-v2 v1.41.5 - github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.6 github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 @@ -348,7 +348,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 6b8e97fb9..4dc6e3b81 100644 --- a/go.sum +++ b/go.sum @@ -1024,7 +1024,13 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= +github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= diff --git a/internal/robotaccount/cas/robotaccount.go b/internal/robotaccount/cas/robotaccount.go index d21dc7af2..39b98ef7b 100644 --- a/internal/robotaccount/cas/robotaccount.go +++ b/internal/robotaccount/cas/robotaccount.go @@ -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. @@ -38,6 +38,13 @@ type Claims struct { StoredSecretID string `json:"secret-id"` // path to the OCI secret in the vault BackendType string `json:"backend"` // backend to use, i.e OCI MaxBytes int64 `json:"maxbytes"` // max bytes to upload + // OrgID identifies the authenticated org this token was minted for. + // Required for managed providers (AWS-S3-ACCESS-POINT) that need to + // scope per-tenant STS sessions; carried as a separate claim from + // StoredSecretID so the binding can't be tampered with by rewriting + // just the secret store. Empty for legacy tokens or providers that + // don't need per-tenant attribution. + OrgID string `json:"org-id,omitempty"` } type Role string @@ -103,7 +110,12 @@ func NewBuilder(opts ...NewOpt) (*Builder, error) { return b, nil } -func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role, maxBytes int64) (string, error) { +// GenerateJWT mints a CAS token. orgID is required for tokens that will +// touch managed providers (e.g. AWS-S3-ACCESS-POINT) and otherwise +// optional — pass "" if the targeted backend doesn't need per-tenant +// attribution. The token always carries the CAS audience and a short +// expiry window. +func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role, maxBytes int64, orgID string) (string, error) { if backendType == "" { return "", fmt.Errorf("backend type is required") } @@ -126,6 +138,7 @@ func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role StoredSecretID: secretID, // Identifier for the backend, i.e OCI BackendType: backendType, + OrgID: orgID, RegisteredClaims: jwt.RegisteredClaims{ Issuer: ra.issuer, Audience: jwt.ClaimStrings{audience}, diff --git a/internal/robotaccount/cas/robotaccount_test.go b/internal/robotaccount/cas/robotaccount_test.go index 0f9e2d7de..6e83f8e32 100644 --- a/internal/robotaccount/cas/robotaccount_test.go +++ b/internal/robotaccount/cas/robotaccount_test.go @@ -150,7 +150,7 @@ func TestGenerateJWT(t *testing.T) { ) require.NoError(t, err) - token, err := b.GenerateJWT("OCI", "secret-id", JWTAudience, Uploader, 123) + token, err := b.GenerateJWT("OCI", "secret-id", JWTAudience, Uploader, 123, "org-uuid") assert.NoError(t, err) assert.NotEmpty(t, token) @@ -167,12 +167,13 @@ func TestGenerateJWT(t *testing.T) { assert.Equal(t, "my-issuer", claims.Issuer) assert.Contains(t, claims.Audience, "artifact-cas.chainloop") assert.Equal(t, claims.MaxBytes, int64(123)) + assert.Equal(t, "org-uuid", claims.OrgID) assert.WithinDuration(t, time.Now(), claims.ExpiresAt.Time, 10*time.Second) } // load key for verification func loadPublicKey(rawKey []byte) jwt.Keyfunc { - return func(token *jwt.Token) (interface{}, error) { + return func(_ *jwt.Token) (any, error) { return jwt.ParseECPublicKeyFromPEM(rawKey) } } diff --git a/pkg/blobmanager/loader/loader.go b/pkg/blobmanager/loader/loader.go index 576091145..cd3ed86b5 100644 --- a/pkg/blobmanager/loader/loader.go +++ b/pkg/blobmanager/loader/loader.go @@ -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. @@ -16,22 +16,65 @@ package loader import ( + "github.com/go-kratos/kratos/v2/log" + backends "github.com/chainloop-dev/chainloop/pkg/blobmanager" "github.com/chainloop-dev/chainloop/pkg/blobmanager/azureblob" "github.com/chainloop-dev/chainloop/pkg/blobmanager/oci" "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/credentials" ) -func LoadProviders(creader credentials.Reader) backends.Providers { - // Initialize CAS backend providers +// Options gathers the optional, deployment-level config blocks that some +// providers need at startup. New providers should add a nilable field +// here, keeping the zero value equivalent to "don't register this +// provider". Passed by pointer so wire can supply it as a normal value. +type Options struct { + // S3AccessPoint enables the AWS-S3-ACCESS-POINT provider. Nil = off. + S3AccessPoint *s3accesspoint.Config + // Logger is used to surface non-fatal provider-init warnings. + // Optional; loader logs to the default kratos logger when nil. + Logger log.Logger +} + +// LoadProviders builds the registry of CAS backend providers consumed by +// both the controlplane and the artifact-cas binaries. The three always-on +// providers (oci, azureblob, s3) are registered unconditionally; the +// access-point provider is only registered when Options.S3AccessPoint is +// non-nil and validates. +// +// A failure to construct a conditional provider logs a warning and is +// otherwise ignored — this keeps a misconfigured s3accesspoint block from +// preventing the binary from starting at all. +// +// Passing a nil Options is valid and equivalent to "register only the +// unconditional providers", so existing test setups don't need to change. +func LoadProviders(creader credentials.Reader, opts *Options) backends.Providers { + if opts == nil { + opts = &Options{} + } + ociProvider := oci.NewBackendProvider(creader) azureBlobProvider := azureblob.NewBackendProvider(creader) s3Provider := s3.NewBackendProvider(creader) - return backends.Providers{ + providers := backends.Providers{ ociProvider.ID(): ociProvider, azureBlobProvider.ID(): azureBlobProvider, s3Provider.ID(): s3Provider, } + + if opts.S3AccessPoint != nil { + apProvider, err := s3accesspoint.NewBackendProvider(opts.S3AccessPoint, creader) + if err != nil { + if opts.Logger != nil { + log.NewHelper(opts.Logger).Warnf("s3accesspoint provider not registered: %v", err) + } + } else { + providers[apProvider.ID()] = apProvider + } + } + + return providers } diff --git a/pkg/blobmanager/loader/loader_test.go b/pkg/blobmanager/loader/loader_test.go new file mode 100644 index 000000000..5dbbe9eeb --- /dev/null +++ b/pkg/blobmanager/loader/loader_test.go @@ -0,0 +1,70 @@ +// +// 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 loader + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/chainloop-dev/chainloop/pkg/blobmanager/azureblob" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/oci" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" +) + +// stubReader satisfies credentials.Reader; never invoked by the registry +// builder. +type stubReader struct{} + +func (stubReader) ReadCredentials(_ context.Context, _ string, _ any) error { return nil } + +func TestLoadProviders_UnconditionalProvidersAlwaysRegistered(t *testing.T) { + t.Parallel() + + for _, opts := range []*Options{nil, {}, {S3AccessPoint: nil}} { + ps := LoadProviders(stubReader{}, opts) + assert.Contains(t, ps, oci.ProviderID) + assert.Contains(t, ps, azureblob.ProviderID) + assert.Contains(t, ps, s3.ProviderID) + assert.NotContains(t, ps, s3accesspoint.ProviderID, + "s3accesspoint must stay off unless explicitly enabled") + } +} + +func TestLoadProviders_RegistersS3AccessPointWhenConfigured(t *testing.T) { + t.Parallel() + + ps := LoadProviders(stubReader{}, &Options{S3AccessPoint: &s3accesspoint.Config{ + BaseRoleARN: "arn:aws:iam::123456789012:role/chainloop-cas-tenant", + Region: "us-east-1", + }}) + assert.Contains(t, ps, s3accesspoint.ProviderID) +} + +func TestLoadProviders_SkipsS3AccessPointOnBadConfig(t *testing.T) { + t.Parallel() + + // Missing required field — provider construction returns an error, + // loader logs a warning and continues without the provider rather + // than panicking. The remaining three providers must still be there. + ps := LoadProviders(stubReader{}, &Options{S3AccessPoint: &s3accesspoint.Config{ + Region: "us-east-1", + }}) + assert.NotContains(t, ps, s3accesspoint.ProviderID) + assert.Contains(t, ps, s3.ProviderID) +} diff --git a/pkg/blobmanager/s3accesspoint/backend.go b/pkg/blobmanager/s3accesspoint/backend.go new file mode 100644 index 000000000..08d8c6398 --- /dev/null +++ b/pkg/blobmanager/s3accesspoint/backend.go @@ -0,0 +1,335 @@ +// +// 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 s3accesspoint + +import ( + "context" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/smithy-go" + pb "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" + backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" +) + +const ( + annotationNameAuthor = "author" + annotationNameFilename = "filename" +) + +// ErrMissingRequestingOrg is returned when a request reaches the backend +// without an org UUID in its context. The backend fails closed in this +// case rather than minting a session with a default/empty name that would +// be useless against an AP policy condition. +var ErrMissingRequestingOrg = errors.New("s3accesspoint: requesting org missing from context (call WithRequestingOrg before upload/download)") + +// Backend is the per-tenant uploader/downloader. One *Backend instance is +// bound to one access point; the actual AWS credentials are minted +// per-request via STS using the org UUID found in the request context. +type Backend struct { + cfg *Config + creds *Credentials + + // stsClient is built once at construction using the pod's ambient + // IAM identity. The credential chain (IRSA → IMDS → env → + // ~/.aws/credentials) picks up the identity automatically. + stsClient *sts.Client + + // s3Client uses a custom CredentialsProvider that mints a scoped + // session per request (cached in-process per requesting-org so back- + // to-back uploads from the same org reuse the token). Bucket is + // always the AP ARN; the SDK accepts an ARN there directly. + s3Client *s3.Client +} + +var _ backend.UploaderDownloader = (*Backend)(nil) + +// NewBackend constructs a *Backend wired to an STS-backed credentials +// provider. ctx is used only for the initial AWS config load (DNS lookups, +// IMDS, IRSA token reads); it is not retained for later operations. +func NewBackend(ctx context.Context, cfg *Config, creds *Credentials) (*Backend, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if err := creds.Validate(); err != nil { + return nil, err + } + + region := cfg.Region + if creds.Region != "" { + region = creds.Region + } + + // Load the pod's ambient AWS identity once. Subsequent SDK calls + // reuse the resulting config; no per-request credential lookup + // against the pod identity is necessary. + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, fmt.Errorf("loading aws config: %w", err) + } + + stsClient := sts.NewFromConfig(awsCfg) + + // The per-request credential provider closes over cfg + creds so it + // can build the session policy from the AP ARN and key prefix every + // time AWS asks for fresh credentials. NewCredentialsCache handles + // proactive refresh and concurrent-call deduplication. + credProvider := aws.NewCredentialsCache(&sessionCredentialsProvider{ + stsClient: stsClient, + baseRoleARN: cfg.BaseRoleARN, + sessionDuration: cfg.SessionDuration, + creds: creds, + }) + + s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.Credentials = credProvider + }) + + return &Backend{ + cfg: cfg, + creds: creds, + stsClient: stsClient, + s3Client: s3Client, + }, nil +} + +// resourceName builds the bucket-level S3 key. Every tenant's objects +// live under their own KeyPrefix so two tenants pushing the same digest +// don't collide at the bucket layer. +func (b *Backend) resourceName(digest string) string { + return fmt.Sprintf("%s/sha256:%s", b.creds.KeyPrefix, digest) +} + +func (b *Backend) Exists(ctx context.Context, digest string) (bool, error) { + _, err := b.Describe(ctx, digest) + if err != nil && backend.IsNotFound(err) { + return false, nil + } + return err == nil, err +} + +func (b *Backend) Upload(ctx context.Context, r io.Reader, resource *pb.CASResource) error { + uploader := manager.NewUploader(b.s3Client) + _, err := uploader.Upload(ctx, &s3.PutObjectInput{ + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(b.resourceName(resource.Digest)), + Body: r, + Metadata: map[string]string{ + annotationNameAuthor: backend.AuthorAnnotation, + annotationNameFilename: resource.FileName, + }, + }) + if err != nil { + return fmt.Errorf("uploading to access point: %w", err) + } + return nil +} + +func (b *Backend) Describe(ctx context.Context, digest string) (*pb.CASResource, error) { + resp, err := b.s3Client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(b.resourceName(digest)), + ChecksumMode: s3types.ChecksumModeEnabled, + }) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" { + return nil, backend.NewErrNotFound("artifact") + } + return nil, fmt.Errorf("reading from access point: %w", err) + } + + // Integrity check: when S3 returned a checksum, make sure the digest + // the caller asked for matches the server's recorded value. + if resp.ChecksumSHA256 != nil && *resp.ChecksumSHA256 != hexSha256ToBinaryB64(digest) { + return nil, fmt.Errorf("failed to validate integrity of object, got=%s, want=%s", + *resp.ChecksumSHA256, hexSha256ToBinaryB64(digest)) + } + + author, ok := resp.Metadata[annotationNameAuthor] + if !ok || author != backend.AuthorAnnotation { + return nil, errors.New("asset not uploaded by Chainloop") + } + fileName, ok := resp.Metadata[annotationNameFilename] + if !ok { + return nil, errors.New("couldn't find file metadata") + } + + var size int64 + if resp.ContentLength != nil { + size = *resp.ContentLength + } + return &pb.CASResource{FileName: fileName, Size: size, Digest: digest}, nil +} + +func (b *Backend) Download(ctx context.Context, w io.Writer, digest string) error { + exists, err := b.Exists(ctx, digest) + if err != nil { + return err + } else if !exists { + return backend.NewErrNotFound("artifact") + } + + downloader := manager.NewDownloader(b.s3Client, func(d *manager.Downloader) { + // Force sequential downloads so the fakeWriterAt below can + // safely ignore the offset argument. + d.Concurrency = 1 + }) + _, err = downloader.Download(ctx, fakeWriterAt{w}, &s3.GetObjectInput{ + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(b.resourceName(digest)), + }) + return err +} + +// CheckWritePermissions verifies that the calling org can actually mint a +// scoped session and put/get an object through its AP. Unlike the regular +// s3 backend's variant this MUST be invoked with a context carrying +// WithRequestingOrg; otherwise it fails closed. +func (b *Backend) CheckWritePermissions(ctx context.Context) error { + if requestingOrgFromContext(ctx) == "" { + return ErrMissingRequestingOrg + } + const testObject = "healthcheck" + key := fmt.Sprintf("%s/%s", b.creds.KeyPrefix, testObject) + + if _, err := b.s3Client.PutObject(ctx, &s3.PutObjectInput{ + Body: strings.NewReader("healthcheckdata"), + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(key), + }); err != nil { + return fmt.Errorf("writing healthcheck object: %w", err) + } + if _, err := b.s3Client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(key), + }); err != nil { + return fmt.Errorf("reading healthcheck object: %w", err) + } + return nil +} + +// sessionCredentialsProvider implements aws.CredentialsProvider. Each +// Retrieve call extracts the requesting org from ctx, builds a session +// policy that scopes the resulting credentials to one AP + one key +// prefix, and calls sts:AssumeRole. +// +// The aws.NewCredentialsCache wrapper around this provider takes care of +// reusing the temporary credentials across consecutive calls until the +// expiration window approaches. +type sessionCredentialsProvider struct { + stsClient *sts.Client + baseRoleARN string + sessionDuration time.Duration + + creds *Credentials +} + +// Retrieve is called by the AWS SDK before every signed request. It must +// be cheap to call (the cache wrapper deduplicates concurrent misses and +// caches valid creds until ExpiresIn). +func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + orgUUID := requestingOrgFromContext(ctx) + if orgUUID == "" { + return aws.Credentials{}, ErrMissingRequestingOrg + } + + // Session policy intersects with the base role's permissions; even + // if the role grants accesspoint/*, this session can only touch the + // caller's AP and prefix. If either field is rewritten by a + // secret-store attacker, the AP's own resource policy (with its + // aws:userid condition matching this orgUUID) is the second line of + // defense. + sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN, p.creds.KeyPrefix) + + durSecs := int32(p.sessionDuration / time.Second) + if durSecs <= 0 { + durSecs = int32(DefaultSessionDuration / time.Second) + } + + out, err := p.stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ + RoleArn: aws.String(p.baseRoleARN), + RoleSessionName: aws.String(roleSessionName(orgUUID)), + Policy: aws.String(sessionPolicy), + DurationSeconds: aws.Int32(durSecs), + }) + if err != nil { + return aws.Credentials{}, fmt.Errorf("sts:AssumeRole for org %s: %w", orgUUID, err) + } + if out.Credentials == nil { + return aws.Credentials{}, errors.New("sts:AssumeRole returned no credentials") + } + + return aws.Credentials{ + AccessKeyID: aws.ToString(out.Credentials.AccessKeyId), + SecretAccessKey: aws.ToString(out.Credentials.SecretAccessKey), + SessionToken: aws.ToString(out.Credentials.SessionToken), + Source: "s3accesspoint", + CanExpire: true, + Expires: aws.ToTime(out.Credentials.Expiration), + }, nil +} + +// roleSessionName binds the AssumeRole session to the requesting org. +// AWS limits session names to 64 chars and a restricted character set; a +// "cas-" string is well within that. +func roleSessionName(orgUUID string) string { + return "cas-" + orgUUID +} + +// buildSessionPolicy returns an IAM policy document that allows only the +// operations the backend actually performs, and only against this +// tenant's AP + key prefix. The Resource ARNs use the AP form +// "${apARN}/object/${keyPrefix}/*". +func buildSessionPolicy(apARN, keyPrefix string) string { + // Minimal, hand-written JSON — keeping it small reduces request + // payload (STS limits session policies to 2048 chars by default). + return fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:GetObjectAttributes"],"Resource":"%s/object/%s/*"}]}`, + apARN, keyPrefix) +} + +// hexSha256ToBinaryB64 decodes the hex sha and re-encodes as base64. S3 +// returns the recorded checksum in base64 form; comparing it to a hex +// digest needs this conversion. +func hexSha256ToBinaryB64(hexString string) string { + decoded, err := hex.DecodeString(hexString) + if err != nil { + return "" + } + return base64.StdEncoding.EncodeToString(decoded) +} + +// fakeWriterAt wraps an io.Writer so the SDK's WriterAt-shaped +// downloader can be driven by a regular writer. Safe only when +// concurrency is forced to 1. +type fakeWriterAt struct { + w io.Writer +} + +func (fw fakeWriterAt) WriteAt(p []byte, _ int64) (int, error) { + return fw.w.Write(p) +} diff --git a/pkg/blobmanager/s3accesspoint/backend_test.go b/pkg/blobmanager/s3accesspoint/backend_test.go new file mode 100644 index 000000000..7b876fe25 --- /dev/null +++ b/pkg/blobmanager/s3accesspoint/backend_test.go @@ -0,0 +1,165 @@ +// +// 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 s3accesspoint + +import ( + "bytes" + "context" + "testing" + + pb "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBackend_FailClosedWithoutRequestingOrg is the load-bearing fail- +// closed test: any backend operation that would normally hit AWS must +// refuse to even attempt the call when the caller forgot to enrich the +// context with WithRequestingOrg. This test does NOT need LocalStack — +// the credential provider rejects the request before any AWS SDK code +// runs. +// +// Not parallel: uses t.Setenv to fence the AWS SDK off from the real +// credential chain. +func TestBackend_FailClosedWithoutRequestingOrg(t *testing.T) { + b := newTestBackend(t) + ctx := context.Background() // intentionally no WithRequestingOrg + + t.Run("upload", func(t *testing.T) { + err := b.Upload(ctx, bytes.NewReader([]byte("data")), + &pb.CASResource{Digest: "deadbeef", FileName: "x.txt"}) + assertFailedClosed(t, err) + }) + + t.Run("download", func(t *testing.T) { + // Download calls Exists -> Describe -> HeadObject, which goes + // through the credentials provider and trips the fail-closed + // path before any AWS call is made. + err := b.Download(ctx, &bytes.Buffer{}, "deadbeef") + assertFailedClosed(t, err) + }) + + t.Run("describe", func(t *testing.T) { + _, err := b.Describe(ctx, "deadbeef") + assertFailedClosed(t, err) + }) + + t.Run("check-write", func(t *testing.T) { + // CheckWritePermissions has its own pre-flight assertion that + // short-circuits without consulting the credentials provider at + // all, which is both faster and gives a cleaner error message + // to operators staring at config. + err := b.CheckWritePermissions(ctx) + require.ErrorIs(t, err, ErrMissingRequestingOrg) + }) +} + +// TestBackend_ResourceNameUsesPerTenantPrefix verifies the bucket-layer +// isolation property: every object the backend reads or writes is +// addressed under the tenant's KeyPrefix. Two tenants pushing the same +// blob digest must produce distinct keys at the underlying bucket level. +// +// Uses stub Backend values directly because resourceName depends only +// on the creds field — no need to spin up SDK clients. +func TestBackend_ResourceNameUsesPerTenantPrefix(t *testing.T) { + t.Parallel() + + bA := &Backend{creds: &Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:111:accesspoint/ap-a", + KeyPrefix: "org/A", + }} + bB := &Backend{creds: &Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:111:accesspoint/ap-b", + KeyPrefix: "org/B", + }} + + digest := "deadbeef" + keyA := bA.resourceName(digest) + keyB := bB.resourceName(digest) + assert.Equal(t, "org/A/sha256:deadbeef", keyA) + assert.Equal(t, "org/B/sha256:deadbeef", keyB) + assert.NotEqual(t, keyA, keyB, "same digest must produce distinct keys across tenants") +} + +// TestSessionPolicy_ScopesToTenantPrefix locks down the session-policy +// generator: the IAM policy minted at AssumeRole time must reference +// both the AP ARN and the tenant key prefix, so a leaked token can't +// touch keys outside its tenant's namespace. +func TestSessionPolicy_ScopesToTenantPrefix(t *testing.T) { + t.Parallel() + + policy := buildSessionPolicy("arn:aws:s3:us-east-1:111:accesspoint/ap-a", "org/A") + + assert.Contains(t, policy, `"arn:aws:s3:us-east-1:111:accesspoint/ap-a/object/org/A/*"`, + "policy Resource must be the AP ARN + tenant prefix") + assert.NotContains(t, policy, `"*"`, + "session policy must not wildcard the Resource") + assert.Contains(t, policy, `"s3:GetObject"`) + assert.Contains(t, policy, `"s3:PutObject"`) +} + +// TestRoleSessionName_DerivedFromOrg pins the session-name shape that +// the AP resource policy condition depends on. Changing the format here +// without updating the AP-side IaC will lock every tenant out. +func TestRoleSessionName_DerivedFromOrg(t *testing.T) { + t.Parallel() + + assert.Equal(t, "cas-abc-123", roleSessionName("abc-123")) +} + +// --- helpers ----------------------------------------------------------- + +// newTestBackend constructs a fully wired *Backend that uses static dummy +// AWS credentials so LoadDefaultConfig doesn't reach out to IMDS/SSO. The +// resulting STS client would only be invoked if a test path slipped past +// the fail-closed guard — in which case the dummy creds would still +// trigger a fast, deterministic failure rather than a real AWS call. +func newTestBackend(t *testing.T) *Backend { + t.Helper() + return backendForCreds(t, &Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org-abc", + KeyPrefix: "org/abc", + }) +} + +func backendForCreds(t *testing.T, creds *Credentials) *Backend { + t.Helper() + t.Setenv("AWS_ACCESS_KEY_ID", "test") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test") + t.Setenv("AWS_REGION", "us-east-1") + // EC2_METADATA_SERVICE_ENDPOINT to a bogus host stops the SDK from + // trying IMDS during config load when no static creds are picked + // up — defensive in case the env-var pickup order changes. + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + + b, err := NewBackend(context.Background(), &Config{ + BaseRoleARN: "arn:aws:iam::123456789012:role/chainloop-cas-tenant", + Region: "us-east-1", + SessionDuration: DefaultSessionDuration, + }, creds) + require.NoError(t, err) + return b +} + +// assertFailedClosed checks that an error originated from the +// missing-context guard, whether returned directly or wrapped by the +// AWS SDK credential-chain machinery. +func assertFailedClosed(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + require.Containsf(t, err.Error(), ErrMissingRequestingOrg.Error(), + "expected fail-closed missing-org error, got %q", err) +} diff --git a/pkg/blobmanager/s3accesspoint/provider.go b/pkg/blobmanager/s3accesspoint/provider.go new file mode 100644 index 000000000..266decf6d --- /dev/null +++ b/pkg/blobmanager/s3accesspoint/provider.go @@ -0,0 +1,241 @@ +// +// 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 s3accesspoint implements a CAS backend that targets a single AWS +// S3 Access Point per tenant. Multiple tenants share one physical bucket; +// per-tenant isolation is provided by: +// +// 1. The Access Point's resource policy, which gates who can address the AP +// and may further restrict s3:prefix. +// 2. A per-request sts:AssumeRole that mints a scoped session whose +// RoleSessionName is derived from the authenticated requesting org +// (carried in the request context via WithRequestingOrg). The AP's +// resource policy enforces a StringEquals on aws:userid so that a +// session minted for org A cannot read or write to org B's AP — even if +// org A's secret blob has been tampered with to point at org B's ARN. +// 3. A KeyPrefix that namespaces every object under /sha256: +// and is also referenced in the session policy's Resource field, so that +// no tenant's request can address keys outside its own prefix. +// +// The session name MUST come from the request context, not from the secret +// blob: a secrets-store compromise alone must not let an attacker reroute +// uploads to another tenant's AP. See WithRequestingOrg. +package s3accesspoint + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" + "github.com/chainloop-dev/chainloop/pkg/credentials" +) + +// ProviderID is the stable identifier used by the CASBackend table's enum +// and by every other place that needs to disambiguate this provider from +// the regular s3 one. +const ProviderID = "AWS-S3-ACCESS-POINT" + +// DefaultSessionDuration is the STS token lifetime used when the deployment +// config doesn't specify one. STS allows up to 12h; 1h keeps blast radius +// of a leaked token small while still giving the credential cache useful +// reuse across consecutive uploads. +const DefaultSessionDuration = time.Hour + +// Config carries the deployment-wide settings the provider needs to mint +// scoped per-tenant credentials. It does NOT contain AWS access keys — the +// pod's ambient IAM identity (IRSA / Pod Identity / instance profile / +// AWS_* env vars) is used to call sts:AssumeRole on BaseRoleARN. +type Config struct { + // BaseRoleARN is the IAM role the controlplane / artifact-cas pod + // assumes via STS at each upload/download. Its permission policy must + // allow s3:{Get,Put,Delete,Head}Object against every access point in + // the account; the per-call session policy narrows that down to one + // AP + one prefix. + BaseRoleARN string + // Region is the default region for the underlying bucket and the + // access points. Individual managed rows may override this via + // Credentials.Region. + Region string + // SessionDuration is the STS token lifetime. Defaults to + // DefaultSessionDuration when zero. + SessionDuration time.Duration +} + +func (c *Config) Validate() error { + if c == nil { + return errors.New("s3accesspoint: nil config") + } + if c.BaseRoleARN == "" { + return errors.New("s3accesspoint: base_role_arn is required") + } + if !strings.HasPrefix(c.BaseRoleARN, "arn:aws:iam::") { + return fmt.Errorf("s3accesspoint: base_role_arn %q is not a valid IAM role ARN", c.BaseRoleARN) + } + if c.Region == "" { + return errors.New("s3accesspoint: region is required") + } + return nil +} + +// Credentials is the per-tenant blob stashed in the secrets manager under +// CASBackend.SecretName. Despite the name it carries no access keys — only +// tenant-identifying coordinates used to construct a scoped S3 client. +// +// The platform reconciler is responsible for writing this blob in lockstep +// with the AWS-side AP creation and policy. +type Credentials struct { + // AccessPointARN, e.g. + // arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org- + // The provider passes this string verbatim as the Bucket parameter on + // every S3 SDK call. + AccessPointARN string + // Region overrides Config.Region for this tenant. Optional; useful if + // the deployment grows multi-region without rolling a new config. + Region string + // KeyPrefix is the per-tenant key namespace inside the underlying + // bucket, e.g. "org/". The provider keys every object under + // "/sha256:" and the session policy's Resource is + // scoped to "${apARN}/object/${KeyPrefix}/*". The AP's resource policy + // is expected to enforce the same prefix via an s3:prefix condition. + KeyPrefix string +} + +func (c *Credentials) Validate() error { + if c == nil { + return fmt.Errorf("%w: nil credentials", backend.ErrValidation) + } + if c.AccessPointARN == "" { + return fmt.Errorf("%w: missing access_point_arn", backend.ErrValidation) + } + if !strings.HasPrefix(c.AccessPointARN, "arn:aws:s3:") || !strings.Contains(c.AccessPointARN, ":accesspoint/") { + return fmt.Errorf("%w: access_point_arn %q is not an S3 access point ARN", backend.ErrValidation, c.AccessPointARN) + } + if c.KeyPrefix == "" { + return fmt.Errorf("%w: missing key_prefix", backend.ErrValidation) + } + if strings.HasPrefix(c.KeyPrefix, "/") || strings.HasSuffix(c.KeyPrefix, "/") { + return fmt.Errorf("%w: key_prefix %q must not start or end with '/'", backend.ErrValidation, c.KeyPrefix) + } + return nil +} + +// BackendProvider implements backend.Provider for the access-point-backed +// managed CAS. Construction validates the deployment Config so a +// misconfigured controlplane fails at startup rather than at first upload. +type BackendProvider struct { + cfg *Config + cReader credentials.Reader +} + +var _ backend.Provider = (*BackendProvider)(nil) + +// NewBackendProvider constructs the provider. It returns an error if cfg +// is missing required fields; callers (typically loader.LoadProviders) are +// expected to skip registration on error so on-prem deployments without +// managed CAS aren't affected. +func NewBackendProvider(cfg *Config, cReader credentials.Reader) (*BackendProvider, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if cReader == nil { + return nil, errors.New("s3accesspoint: credentials reader is required") + } + // Normalize default session duration so downstream code can rely on a + // non-zero value without re-checking everywhere. + if cfg.SessionDuration == 0 { + cfg.SessionDuration = DefaultSessionDuration + } + return &BackendProvider{cfg: cfg, cReader: cReader}, nil +} + +func (p *BackendProvider) ID() string { + return ProviderID +} + +// FromCredentials reads the per-tenant Credentials blob from the secrets +// manager and constructs a *Backend bound to that tenant's AP. +// +// The returned UploaderDownloader is safe to reuse across requests; each +// request must enrich its context with WithRequestingOrg so the STS-minted +// session name matches the AP's resource-policy condition. +func (p *BackendProvider) FromCredentials(ctx context.Context, secretName string) (backend.UploaderDownloader, error) { + creds := &Credentials{} + if err := p.cReader.ReadCredentials(ctx, secretName, creds); err != nil { + return nil, err + } + if err := creds.Validate(); err != nil { + return nil, fmt.Errorf("invalid credentials retrieved from storage: %w", err) + } + return NewBackend(ctx, p.cfg, creds) +} + +// ValidateAndExtractCredentials decodes credsJSON into a Credentials struct +// and optionally cross-checks it against the location passed by the caller. +// This is invoked when a managed row is being created or revalidated; the +// returned value is what gets persisted in the secrets manager by upstream +// callers. +// +// Unlike the regular s3 provider, this does NOT exercise live S3 +// permissions during validation: the credentials by themselves can't be +// tested without a request-context org UUID (see WithRequestingOrg), so a +// proper end-to-end check belongs in the upload path. PerformValidation in +// the controlplane still calls this method for managed rows; it will +// succeed as long as the blob is well-formed. +func (p *BackendProvider) ValidateAndExtractCredentials(location string, credsJSON []byte) (any, error) { + var creds Credentials + if err := json.Unmarshal(credsJSON, &creds); err != nil { + return nil, fmt.Errorf("unmarshaling credentials: %w", err) + } + if err := creds.Validate(); err != nil { + return nil, fmt.Errorf("invalid credentials: %w", err) + } + // If the caller supplied a location, it must agree with the blob. + // This is a denormalization sanity check, not a security boundary — + // the security boundary is the AP resource policy on the AWS side. + if location != "" && location != creds.AccessPointARN { + return nil, fmt.Errorf("%w: location %q does not match access_point_arn %q", + backend.ErrValidation, location, creds.AccessPointARN) + } + return &creds, nil +} + +// requestingOrgCtxKey is unexported so callers must go through +// WithRequestingOrg / requestingOrgFromContext; no risk of accidental +// collision with another package's keys. +type requestingOrgCtxKey struct{} + +// WithRequestingOrg returns a derived context that carries the +// authenticated requesting organization's UUID. Every biz/service path +// that hands a managed-AP backend off to Upload/Download MUST enrich the +// ctx via this helper; without it the backend fails closed. +// +// The value MUST come from the request's authenticated identity (e.g. +// usercontext.CurrentOrg(ctx).ID), NOT from the resolved CASBackend or its +// secret blob. The whole secret-tampering defense depends on this being a +// source the attacker can't rewrite together with the secret store. +func WithRequestingOrg(ctx context.Context, orgUUID string) context.Context { + return context.WithValue(ctx, requestingOrgCtxKey{}, orgUUID) +} + +// requestingOrgFromContext extracts the requesting org UUID. Empty string +// means "no caller set the key" — the backend treats this as a hard error. +func requestingOrgFromContext(ctx context.Context) string { + v, _ := ctx.Value(requestingOrgCtxKey{}).(string) + return v +} diff --git a/pkg/blobmanager/s3accesspoint/provider_test.go b/pkg/blobmanager/s3accesspoint/provider_test.go new file mode 100644 index 000000000..54330f16a --- /dev/null +++ b/pkg/blobmanager/s3accesspoint/provider_test.go @@ -0,0 +1,198 @@ +// +// 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 s3accesspoint + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validCreds is reused across the unit tests as a known-good baseline. +// Each test case clones and mutates it so we can express what's missing +// rather than what's present. +func validCreds() Credentials { + return Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org-abc", + Region: "us-east-1", + KeyPrefix: "org/abc", + } +} + +func TestConfig_Validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + cfg *Config + wantErr string + }{ + {"nil config", nil, "nil config"}, + {"missing role arn", &Config{Region: "us-east-1"}, "base_role_arn is required"}, + {"malformed role arn", &Config{BaseRoleARN: "not-an-arn", Region: "us-east-1"}, "not a valid IAM role ARN"}, + {"missing region", &Config{BaseRoleARN: "arn:aws:iam::1:role/r"}, "region is required"}, + {"happy", &Config{BaseRoleARN: "arn:aws:iam::1:role/r", Region: "us-east-1"}, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.Validate() + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + assert.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestCredentials_Validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(*Credentials) + wantErr string + }{ + {"happy", func(*Credentials) {}, ""}, + { + name: "missing arn", + mutate: func(c *Credentials) { c.AccessPointARN = "" }, + wantErr: "missing access_point_arn", + }, + { + name: "not an AP arn", + mutate: func(c *Credentials) { c.AccessPointARN = "arn:aws:s3:::some-bucket" }, + wantErr: "not an S3 access point ARN", + }, + { + name: "missing prefix", + mutate: func(c *Credentials) { c.KeyPrefix = "" }, + wantErr: "missing key_prefix", + }, + { + name: "prefix leading slash", + mutate: func(c *Credentials) { c.KeyPrefix = "/org/abc" }, + wantErr: "must not start or end with", + }, + { + name: "prefix trailing slash", + mutate: func(c *Credentials) { c.KeyPrefix = "org/abc/" }, + wantErr: "must not start or end with", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := validCreds() + tc.mutate(&c) + err := c.Validate() + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + assert.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestValidateAndExtractCredentials(t *testing.T) { + t.Parallel() + + good := validCreds() + goodJSON, _ := json.Marshal(good) + + // Same content but mismatched location passed alongside. + wrongLocation := good.AccessPointARN + "-tampered" + + tests := []struct { + name string + location string + body []byte + wantErr string + }{ + {"valid no location", "", goodJSON, ""}, + {"valid matching location", good.AccessPointARN, goodJSON, ""}, + {"location mismatch", wrongLocation, goodJSON, "does not match access_point_arn"}, + {"malformed JSON", "", []byte("{not json"), "unmarshaling"}, + {"missing field", "", []byte(`{"AccessPointARN":""}`), "missing access_point_arn"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &BackendProvider{cfg: &Config{ + BaseRoleARN: "arn:aws:iam::1:role/r", Region: "us-east-1", + }} + out, err := p.ValidateAndExtractCredentials(tc.location, tc.body) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + assert.Nil(t, out) + return + } + require.NoError(t, err) + creds, ok := out.(*Credentials) + require.True(t, ok, "expected *Credentials, got %T", out) + assert.Equal(t, good.AccessPointARN, creds.AccessPointARN) + assert.Equal(t, good.KeyPrefix, creds.KeyPrefix) + }) + } +} + +func TestNewBackendProvider_NormalizesSessionDuration(t *testing.T) { + cfg := &Config{ + BaseRoleARN: "arn:aws:iam::1:role/r", + Region: "us-east-1", + // Intentionally zero — provider should fill the default. + } + p, err := NewBackendProvider(cfg, stubReader{}) + require.NoError(t, err) + assert.Equal(t, ProviderID, p.ID()) + assert.Equal(t, DefaultSessionDuration, p.cfg.SessionDuration) + + custom := 5 * time.Minute + cfg2 := &Config{BaseRoleARN: cfg.BaseRoleARN, Region: cfg.Region, SessionDuration: custom} + p2, err := NewBackendProvider(cfg2, stubReader{}) + require.NoError(t, err) + assert.Equal(t, custom, p2.cfg.SessionDuration) +} + +func TestWithRequestingOrg_RoundTrip(t *testing.T) { + // Empty by default. + assert.Empty(t, requestingOrgFromContext(context.Background())) + + ctx := WithRequestingOrg(context.Background(), "org-abc") + assert.Equal(t, "org-abc", requestingOrgFromContext(ctx)) + + // Overwrite is allowed and uses the most recent value (mirrors + // context.WithValue semantics — important so a middleware that sets + // the org doesn't get silently overridden by a stale value further + // down the stack). + ctx = WithRequestingOrg(ctx, "org-xyz") + assert.Equal(t, "org-xyz", requestingOrgFromContext(ctx)) +} + +func TestNewBackendProvider_FailsOnBadConfig(t *testing.T) { + _, err := NewBackendProvider(&Config{Region: "us-east-1"}, stubReader{}) + assert.ErrorContains(t, err, "base_role_arn") + + _, err = NewBackendProvider(&Config{BaseRoleARN: "arn:aws:iam::1:role/r", Region: "us-east-1"}, nil) + assert.ErrorContains(t, err, "credentials reader is required") +} + +// stubReader is the minimal credentials.Reader implementation needed to +// exercise constructor wiring; the unit tests never invoke it. +type stubReader struct{} + +func (stubReader) ReadCredentials(_ context.Context, _ string, _ any) error { return nil } From b0a76613c47ef90612662d1a48563fd21682af06 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 15 May 2026 19:52:37 +0200 Subject: [PATCH 02/18] refactor(blobmanager): derive s3accesspoint key prefix from ctx + add dev mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related refinements to the AWS-S3-ACCESS-POINT provider. 1. The per-tenant key prefix is now derived at request time from the authenticated requesting org carried in ctx via WithRequestingOrg, rather than read from a `KeyPrefix` field in the secrets-manager blob. The prefix and the AssumeRole `RoleSessionName` now share their single source of truth, so a tampered Credentials blob can no longer reroute a tenant's writes into another tenant's namespace. The Credentials struct shrinks to {AccessPointARN, Region}. The session policy and the bucket-level key both use `` as the prefix; the AP resource policy's Resource ARN must be `${apARN}/object//*` to match. 2. Add a `dev_mode_use_ambient_credentials` Config flag (proto + wire-plumbed in both binaries) that bypasses `sts:AssumeRole` and routes S3 calls through whatever ambient AWS identity the SDK's default credential chain produced. Local dev no longer requires an IAM role + trust policy setup. The missing-org fail-closed check still fires in dev mode so callers that forget WithRequestingOrg surface the same bug locally that they would in production. A loud warning is logged at startup. DEV ONLY — never enable in multi-tenant deployments. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- app/artifact-cas/cmd/wire.go | 7 +- app/artifact-cas/cmd/wire_gen.go | 7 +- app/artifact-cas/configs/config.devel.yaml | 4 + app/artifact-cas/internal/conf/conf.pb.go | 21 +++-- app/artifact-cas/internal/conf/conf.proto | 3 + app/controlplane/cmd/wire.go | 7 +- app/controlplane/cmd/wire_gen.go | 7 +- app/controlplane/configs/config.devel.yaml | 4 + .../conf/controlplane/config/v1/conf.pb.go | 33 +++++-- .../conf/controlplane/config/v1/conf.proto | 13 ++- pkg/blobmanager/s3accesspoint/backend.go | 86 +++++++++++++----- pkg/blobmanager/s3accesspoint/backend_test.go | 88 +++++++++++++++---- pkg/blobmanager/s3accesspoint/provider.go | 71 ++++++++++----- .../s3accesspoint/provider_test.go | 37 ++++---- 14 files changed, 282 insertions(+), 106 deletions(-) diff --git a/app/artifact-cas/cmd/wire.go b/app/artifact-cas/cmd/wire.go index e16cf0381..61929164f 100644 --- a/app/artifact-cas/cmd/wire.go +++ b/app/artifact-cas/cmd/wire.go @@ -59,9 +59,10 @@ func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { } ap := in.GetS3AccessPoint() opts.S3AccessPoint = &s3accesspoint.Config{ - BaseRoleARN: ap.GetBaseRoleArn(), - Region: ap.GetRegion(), - SessionDuration: ap.GetSessionDuration().AsDuration(), + BaseRoleARN: ap.GetBaseRoleArn(), + Region: ap.GetRegion(), + SessionDuration: ap.GetSessionDuration().AsDuration(), + DevModeUseAmbientCredentials: ap.GetDevModeUseAmbientCredentials(), } return opts } diff --git a/app/artifact-cas/cmd/wire_gen.go b/app/artifact-cas/cmd/wire_gen.go index 4ef780ede..20339454c 100644 --- a/app/artifact-cas/cmd/wire_gen.go +++ b/app/artifact-cas/cmd/wire_gen.go @@ -71,9 +71,10 @@ func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { } ap := in.GetS3AccessPoint() opts.S3AccessPoint = &s3accesspoint.Config{ - BaseRoleARN: ap.GetBaseRoleArn(), - Region: ap.GetRegion(), - SessionDuration: ap.GetSessionDuration().AsDuration(), + BaseRoleARN: ap.GetBaseRoleArn(), + Region: ap.GetRegion(), + SessionDuration: ap.GetSessionDuration().AsDuration(), + DevModeUseAmbientCredentials: ap.GetDevModeUseAmbientCredentials(), } return opts } diff --git a/app/artifact-cas/configs/config.devel.yaml b/app/artifact-cas/configs/config.devel.yaml index a86e4d333..11b477ff2 100644 --- a/app/artifact-cas/configs/config.devel.yaml +++ b/app/artifact-cas/configs/config.devel.yaml @@ -49,3 +49,7 @@ auth: # base_role_arn: arn:aws:iam::123456789012:role/chainloop-cas-tenant # region: us-east-1 # session_duration: 1h +# # DEV ONLY: bypass sts:AssumeRole and use whatever AWS identity the +# # SDK default credential chain produces (env vars, ~/.aws/credentials, +# # IRSA, …). Skips per-tenant isolation; never enable in production. +# # dev_mode_use_ambient_credentials: true diff --git a/app/artifact-cas/internal/conf/conf.pb.go b/app/artifact-cas/internal/conf/conf.pb.go index e623537f2..0922d0b25 100644 --- a/app/artifact-cas/internal/conf/conf.pb.go +++ b/app/artifact-cas/internal/conf/conf.pb.go @@ -703,8 +703,11 @@ type BlobBackends_S3AccessPoint struct { BaseRoleArn string `protobuf:"bytes,1,opt,name=base_role_arn,json=baseRoleArn,proto3" json:"base_role_arn,omitempty"` Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` SessionDuration *durationpb.Duration `protobuf:"bytes,3,opt,name=session_duration,json=sessionDuration,proto3" json:"session_duration,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // DEV ONLY — see controlplane proto for full doc. Bypasses + // sts:AssumeRole and uses the pod's ambient AWS identity directly. + DevModeUseAmbientCredentials bool `protobuf:"varint,4,opt,name=dev_mode_use_ambient_credentials,json=devModeUseAmbientCredentials,proto3" json:"dev_mode_use_ambient_credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *BlobBackends_S3AccessPoint) Reset() { @@ -758,6 +761,13 @@ func (x *BlobBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { return nil } +func (x *BlobBackends_S3AccessPoint) GetDevModeUseAmbientCredentials() bool { + if x != nil { + return x.DevModeUseAmbientCredentials + } + return false +} + var File_conf_proto protoreflect.FileDescriptor const file_conf_proto_rawDesc = "" + @@ -802,13 +812,14 @@ const file_conf_proto_rawDesc = "" + "\x04addr\x18\x02 \x01(\tR\x04addr\x123\n" + "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12*\n" + "\n" + - "tls_config\x18\x04 \x01(\v2\v.Server.TLSR\ttlsConfig\"\xe7\x01\n" + + "tls_config\x18\x04 \x01(\v2\v.Server.TLSR\ttlsConfig\"\xaf\x02\n" + "\fBlobBackends\x12C\n" + - "\x0fs3_access_point\x18\x01 \x01(\v2\x1b.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\x91\x01\n" + + "\x0fs3_access_point\x18\x01 \x01(\v2\x1b.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\xd9\x01\n" + "\rS3AccessPoint\x12\"\n" + "\rbase_role_arn\x18\x01 \x01(\tR\vbaseRoleArn\x12\x16\n" + "\x06region\x18\x02 \x01(\tR\x06region\x12D\n" + - "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\"t\n" + + "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\x12F\n" + + " dev_mode_use_ambient_credentials\x18\x04 \x01(\bR\x1cdevModeUseAmbientCredentials\"t\n" + "\x04Auth\x12D\n" + "\x1drobot_account_public_key_path\x18\x01 \x01(\tB\x02\x18\x01R\x19robotAccountPublicKeyPath\x12&\n" + "\x0fpublic_key_path\x18\x02 \x01(\tR\rpublicKeyPathBHZFgithub.com/chainloop-dev/chainloop/app/artifact-cas/internal/conf;confb\x06proto3" diff --git a/app/artifact-cas/internal/conf/conf.proto b/app/artifact-cas/internal/conf/conf.proto index 5b53f2cc4..b69c78591 100644 --- a/app/artifact-cas/internal/conf/conf.proto +++ b/app/artifact-cas/internal/conf/conf.proto @@ -94,6 +94,9 @@ message BlobBackends { string base_role_arn = 1; string region = 2; google.protobuf.Duration session_duration = 3; + // DEV ONLY — see controlplane proto for full doc. Bypasses + // sts:AssumeRole and uses the pod's ambient AWS identity directly. + bool dev_mode_use_ambient_credentials = 4; } } diff --git a/app/controlplane/cmd/wire.go b/app/controlplane/cmd/wire.go index 0728051d9..27f64a28a 100644 --- a/app/controlplane/cmd/wire.go +++ b/app/controlplane/cmd/wire.go @@ -140,9 +140,10 @@ func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { } ap := in.GetS3AccessPoint() opts.S3AccessPoint = &s3accesspoint.Config{ - BaseRoleARN: ap.GetBaseRoleArn(), - Region: ap.GetRegion(), - SessionDuration: ap.GetSessionDuration().AsDuration(), + BaseRoleARN: ap.GetBaseRoleArn(), + Region: ap.GetRegion(), + SessionDuration: ap.GetSessionDuration().AsDuration(), + DevModeUseAmbientCredentials: ap.GetDevModeUseAmbientCredentials(), } return opts } diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index eed3dcb3e..7c57eb661 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -480,9 +480,10 @@ func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { } ap := in.GetS3AccessPoint() opts.S3AccessPoint = &s3accesspoint.Config{ - BaseRoleARN: ap.GetBaseRoleArn(), - Region: ap.GetRegion(), - SessionDuration: ap.GetSessionDuration().AsDuration(), + BaseRoleARN: ap.GetBaseRoleArn(), + Region: ap.GetRegion(), + SessionDuration: ap.GetSessionDuration().AsDuration(), + DevModeUseAmbientCredentials: ap.GetDevModeUseAmbientCredentials(), } return opts } diff --git a/app/controlplane/configs/config.devel.yaml b/app/controlplane/configs/config.devel.yaml index f6498c578..968a406b0 100644 --- a/app/controlplane/configs/config.devel.yaml +++ b/app/controlplane/configs/config.devel.yaml @@ -135,3 +135,7 @@ attestations: # base_role_arn: arn:aws:iam::123456789012:role/chainloop-cas-tenant # region: us-east-1 # session_duration: 1h +# # DEV ONLY: bypass sts:AssumeRole and use whatever AWS identity the +# # SDK default credential chain produces (env vars, ~/.aws/credentials, +# # IRSA, …). Skips per-tenant isolation; never enable in production. +# # dev_mode_use_ambient_credentials: true diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go index ca64eb94f..8e210dab4 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go @@ -1347,15 +1347,22 @@ type BlobBackends_S3AccessPoint struct { state protoimpl.MessageState `protogen:"open.v1"` // IAM role the controlplane / artifact-cas pod assumes per request // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every - // access point in the account. + // access point in the account. Required in production; may be empty + // when dev_mode_use_ambient_credentials is true. BaseRoleArn string `protobuf:"bytes,1,opt,name=base_role_arn,json=baseRoleArn,proto3" json:"base_role_arn,omitempty"` // Default AWS region for the underlying bucket and access points. // Individual managed CASBackend rows can override per-tenant. Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` - // STS token lifetime. Defaults to 1h when unset. + // STS token lifetime. Defaults to 1h when unset. Ignored in dev mode. SessionDuration *durationpb.Duration `protobuf:"bytes,3,opt,name=session_duration,json=sessionDuration,proto3" json:"session_duration,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // dev_mode_use_ambient_credentials short-circuits sts:AssumeRole and + // routes S3 calls through whatever ambient AWS identity the SDK's + // default credential chain produced (env vars, ~/.aws/credentials, + // instance profile, IRSA, …). DEV ONLY — this bypasses per-tenant + // isolation and MUST NOT be set in multi-tenant deployments. + DevModeUseAmbientCredentials bool `protobuf:"varint,4,opt,name=dev_mode_use_ambient_credentials,json=devModeUseAmbientCredentials,proto3" json:"dev_mode_use_ambient_credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *BlobBackends_S3AccessPoint) Reset() { @@ -1409,6 +1416,13 @@ func (x *BlobBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { return nil } +func (x *BlobBackends_S3AccessPoint) GetDevModeUseAmbientCredentials() bool { + if x != nil { + return x.DevModeUseAmbientCredentials + } + return false +} + type Server_HTTP struct { state protoimpl.MessageState `protogen:"open.v1"` Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` @@ -1969,13 +1983,14 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x03uri\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x03uri\x12\x1f\n" + "\x05token\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x05token\x12\x1a\n" + "\breplicas\x18\x03 \x01(\x05R\breplicasB\x10\n" + - "\x0eauthentication\"\x90\x02\n" + + "\x0eauthentication\"\xcf\x02\n" + "\fBlobBackends\x12Z\n" + - "\x0fs3_access_point\x18\x01 \x01(\v22.controlplane.config.v1.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\xa3\x01\n" + - "\rS3AccessPoint\x12+\n" + - "\rbase_role_arn\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\vbaseRoleArn\x12\x1f\n" + + "\x0fs3_access_point\x18\x01 \x01(\v22.controlplane.config.v1.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\xe2\x01\n" + + "\rS3AccessPoint\x12\"\n" + + "\rbase_role_arn\x18\x01 \x01(\tR\vbaseRoleArn\x12\x1f\n" + "\x06region\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06region\x12D\n" + - "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\"6\n" + + "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\x12F\n" + + " dev_mode_use_ambient_credentials\x18\x04 \x01(\bR\x1cdevModeUseAmbientCredentials\"6\n" + "\fAttestations\x12&\n" + "\x0fskip_db_storage\x18\x01 \x01(\bR\rskipDbStorage\"V\n" + "\x1eOperationAuthorizationProvider\x12\x1a\n" + diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto index 5841a8d38..c6d3c458f 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto @@ -147,13 +147,20 @@ message BlobBackends { message S3AccessPoint { // IAM role the controlplane / artifact-cas pod assumes per request // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every - // access point in the account. - string base_role_arn = 1 [(buf.validate.field).string.min_len = 1]; + // access point in the account. Required in production; may be empty + // when dev_mode_use_ambient_credentials is true. + string base_role_arn = 1; // Default AWS region for the underlying bucket and access points. // Individual managed CASBackend rows can override per-tenant. string region = 2 [(buf.validate.field).string.min_len = 1]; - // STS token lifetime. Defaults to 1h when unset. + // STS token lifetime. Defaults to 1h when unset. Ignored in dev mode. google.protobuf.Duration session_duration = 3; + // dev_mode_use_ambient_credentials short-circuits sts:AssumeRole and + // routes S3 calls through whatever ambient AWS identity the SDK's + // default credential chain produced (env vars, ~/.aws/credentials, + // instance profile, IRSA, …). DEV ONLY — this bypasses per-tenant + // isolation and MUST NOT be set in multi-tenant deployments. + bool dev_mode_use_ambient_credentials = 4; } } diff --git a/pkg/blobmanager/s3accesspoint/backend.go b/pkg/blobmanager/s3accesspoint/backend.go index 08d8c6398..8ac279b1a 100644 --- a/pkg/blobmanager/s3accesspoint/backend.go +++ b/pkg/blobmanager/s3accesspoint/backend.go @@ -98,11 +98,17 @@ func NewBackend(ctx context.Context, cfg *Config, creds *Credentials) (*Backend, // can build the session policy from the AP ARN and key prefix every // time AWS asks for fresh credentials. NewCredentialsCache handles // proactive refresh and concurrent-call deduplication. + // + // In dev mode we hand the provider the ambient credentials so it can + // return them directly without calling STS. The provider still + // enforces the requesting-org context discipline. credProvider := aws.NewCredentialsCache(&sessionCredentialsProvider{ - stsClient: stsClient, - baseRoleARN: cfg.BaseRoleARN, - sessionDuration: cfg.SessionDuration, - creds: creds, + stsClient: stsClient, + ambientCreds: awsCfg.Credentials, + baseRoleARN: cfg.BaseRoleARN, + sessionDuration: cfg.SessionDuration, + useAmbientForRetrieve: cfg.DevModeUseAmbientCredentials, + creds: creds, }) s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { @@ -117,11 +123,18 @@ func NewBackend(ctx context.Context, cfg *Config, creds *Credentials) (*Backend, }, nil } -// resourceName builds the bucket-level S3 key. Every tenant's objects -// live under their own KeyPrefix so two tenants pushing the same digest -// don't collide at the bucket layer. -func (b *Backend) resourceName(digest string) string { - return fmt.Sprintf("%s/sha256:%s", b.creds.KeyPrefix, digest) +// keyFor builds the bucket-level S3 key for a digest. Every tenant's +// objects live under a prefix derived from the requesting org carried in +// ctx, so two tenants pushing the same digest don't collide at the bucket +// layer. The function fails closed when the org is missing — same +// invariant the credentials provider enforces, just surfaced earlier +// with a clearer error. +func (b *Backend) keyFor(ctx context.Context, digest string) (string, error) { + orgUUID := requestingOrgFromContext(ctx) + if orgUUID == "" { + return "", ErrMissingRequestingOrg + } + return fmt.Sprintf("%s/sha256:%s", orgUUID, digest), nil } func (b *Backend) Exists(ctx context.Context, digest string) (bool, error) { @@ -133,10 +146,14 @@ func (b *Backend) Exists(ctx context.Context, digest string) (bool, error) { } func (b *Backend) Upload(ctx context.Context, r io.Reader, resource *pb.CASResource) error { + key, err := b.keyFor(ctx, resource.Digest) + if err != nil { + return err + } uploader := manager.NewUploader(b.s3Client) - _, err := uploader.Upload(ctx, &s3.PutObjectInput{ + _, err = uploader.Upload(ctx, &s3.PutObjectInput{ Bucket: aws.String(b.creds.AccessPointARN), - Key: aws.String(b.resourceName(resource.Digest)), + Key: aws.String(key), Body: r, Metadata: map[string]string{ annotationNameAuthor: backend.AuthorAnnotation, @@ -150,9 +167,13 @@ func (b *Backend) Upload(ctx context.Context, r io.Reader, resource *pb.CASResou } func (b *Backend) Describe(ctx context.Context, digest string) (*pb.CASResource, error) { + key, err := b.keyFor(ctx, digest) + if err != nil { + return nil, err + } resp, err := b.s3Client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(b.creds.AccessPointARN), - Key: aws.String(b.resourceName(digest)), + Key: aws.String(key), ChecksumMode: s3types.ChecksumModeEnabled, }) if err != nil { @@ -194,6 +215,10 @@ func (b *Backend) Download(ctx context.Context, w io.Writer, digest string) erro return backend.NewErrNotFound("artifact") } + key, err := b.keyFor(ctx, digest) + if err != nil { + return err + } downloader := manager.NewDownloader(b.s3Client, func(d *manager.Downloader) { // Force sequential downloads so the fakeWriterAt below can // safely ignore the offset argument. @@ -201,7 +226,7 @@ func (b *Backend) Download(ctx context.Context, w io.Writer, digest string) erro }) _, err = downloader.Download(ctx, fakeWriterAt{w}, &s3.GetObjectInput{ Bucket: aws.String(b.creds.AccessPointARN), - Key: aws.String(b.resourceName(digest)), + Key: aws.String(key), }) return err } @@ -211,11 +236,12 @@ func (b *Backend) Download(ctx context.Context, w io.Writer, digest string) erro // s3 backend's variant this MUST be invoked with a context carrying // WithRequestingOrg; otherwise it fails closed. func (b *Backend) CheckWritePermissions(ctx context.Context) error { - if requestingOrgFromContext(ctx) == "" { + orgUUID := requestingOrgFromContext(ctx) + if orgUUID == "" { return ErrMissingRequestingOrg } const testObject = "healthcheck" - key := fmt.Sprintf("%s/%s", b.creds.KeyPrefix, testObject) + key := fmt.Sprintf("%s/%s", orgUUID, testObject) if _, err := b.s3Client.PutObject(ctx, &s3.PutObjectInput{ Body: strings.NewReader("healthcheckdata"), @@ -246,6 +272,15 @@ type sessionCredentialsProvider struct { baseRoleARN string sessionDuration time.Duration + // ambientCreds is the SDK-default credentials provider captured from + // awsCfg at construction time. Only consulted when + // useAmbientForRetrieve is true (dev mode). + ambientCreds aws.CredentialsProvider + // useAmbientForRetrieve short-circuits Retrieve to return the pod's + // ambient AWS credentials directly without calling sts:AssumeRole. + // DEV ONLY — see Config.DevModeUseAmbientCredentials. + useAmbientForRetrieve bool + creds *Credentials } @@ -258,13 +293,24 @@ func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credenti return aws.Credentials{}, ErrMissingRequestingOrg } + // Dev mode: skip the per-request AssumeRole entirely and use the + // SDK's default credential chain directly. We still required the + // org-from-ctx check above so callers that forget WithRequestingOrg + // fail the same way they would in production. + if p.useAmbientForRetrieve { + if p.ambientCreds == nil { + return aws.Credentials{}, errors.New("s3accesspoint: dev mode requested but no ambient credentials available") + } + return p.ambientCreds.Retrieve(ctx) + } + // Session policy intersects with the base role's permissions; even // if the role grants accesspoint/*, this session can only touch the - // caller's AP and prefix. If either field is rewritten by a - // secret-store attacker, the AP's own resource policy (with its - // aws:userid condition matching this orgUUID) is the second line of - // defense. - sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN, p.creds.KeyPrefix) + // caller's AP and prefix. The prefix is the requesting-org UUID + // straight from ctx — same source as the session name — so a + // tampered AccessPointARN in the secret blob can't widen the prefix + // scope to escape into another tenant's namespace. + sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN, orgUUID) durSecs := int32(p.sessionDuration / time.Second) if durSecs <= 0 { diff --git a/pkg/blobmanager/s3accesspoint/backend_test.go b/pkg/blobmanager/s3accesspoint/backend_test.go index 7b876fe25..c9baefa41 100644 --- a/pkg/blobmanager/s3accesspoint/backend_test.go +++ b/pkg/blobmanager/s3accesspoint/backend_test.go @@ -20,6 +20,7 @@ import ( "context" "testing" + "github.com/aws/aws-sdk-go-v2/aws" pb "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -67,31 +68,30 @@ func TestBackend_FailClosedWithoutRequestingOrg(t *testing.T) { }) } -// TestBackend_ResourceNameUsesPerTenantPrefix verifies the bucket-layer +// TestBackend_KeyDerivedFromRequestingOrg verifies the bucket-layer // isolation property: every object the backend reads or writes is -// addressed under the tenant's KeyPrefix. Two tenants pushing the same -// blob digest must produce distinct keys at the underlying bucket level. -// -// Uses stub Backend values directly because resourceName depends only -// on the creds field — no need to spin up SDK clients. -func TestBackend_ResourceNameUsesPerTenantPrefix(t *testing.T) { +// addressed under a prefix derived from the requesting org in ctx. +// One Backend invoked with two different ctx-orgs must produce distinct +// keys for the same digest, and an empty ctx must error out. +func TestBackend_KeyDerivedFromRequestingOrg(t *testing.T) { t.Parallel() - bA := &Backend{creds: &Credentials{ + b := &Backend{creds: &Credentials{ AccessPointARN: "arn:aws:s3:us-east-1:111:accesspoint/ap-a", - KeyPrefix: "org/A", - }} - bB := &Backend{creds: &Credentials{ - AccessPointARN: "arn:aws:s3:us-east-1:111:accesspoint/ap-b", - KeyPrefix: "org/B", }} - digest := "deadbeef" - keyA := bA.resourceName(digest) - keyB := bB.resourceName(digest) - assert.Equal(t, "org/A/sha256:deadbeef", keyA) - assert.Equal(t, "org/B/sha256:deadbeef", keyB) + + keyA, err := b.keyFor(WithRequestingOrg(context.Background(), "org-A"), digest) + require.NoError(t, err) + keyB, err := b.keyFor(WithRequestingOrg(context.Background(), "org-B"), digest) + require.NoError(t, err) + + assert.Equal(t, "org-A/sha256:deadbeef", keyA) + assert.Equal(t, "org-B/sha256:deadbeef", keyB) assert.NotEqual(t, keyA, keyB, "same digest must produce distinct keys across tenants") + + _, err = b.keyFor(context.Background(), digest) + require.ErrorIs(t, err, ErrMissingRequestingOrg) } // TestSessionPolicy_ScopesToTenantPrefix locks down the session-policy @@ -120,6 +120,57 @@ func TestRoleSessionName_DerivedFromOrg(t *testing.T) { assert.Equal(t, "cas-abc-123", roleSessionName("abc-123")) } +// TestSessionCredentialsProvider_DevModeShortCircuit verifies that the +// dev-mode bypass calls the ambient credentials provider instead of STS, +// and crucially that the missing-org fail-closed check still fires even +// in dev mode — so developers don't accidentally let an obvious bug +// through. +func TestSessionCredentialsProvider_DevModeShortCircuit(t *testing.T) { + t.Parallel() + + ambient := &countingCredsProvider{ + creds: aws.Credentials{AccessKeyID: "AKDEV", SecretAccessKey: "secret", Source: "test"}, + } + p := &sessionCredentialsProvider{ + ambientCreds: ambient, + useAmbientForRetrieve: true, + creds: &Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:111:accesspoint/ap-a", + }, + // stsClient deliberately nil; if dev mode short-circuits properly + // it should never be touched. A non-nil pointer here would mask + // regressions. + } + + t.Run("returns ambient credentials when org is set", func(t *testing.T) { + ctx := WithRequestingOrg(context.Background(), "org-A") + got, err := p.Retrieve(ctx) + require.NoError(t, err) + assert.Equal(t, "AKDEV", got.AccessKeyID) + assert.Equal(t, 1, ambient.calls) + }) + + t.Run("still fails closed without requesting org", func(t *testing.T) { + ambient.calls = 0 + _, err := p.Retrieve(context.Background()) + require.ErrorIs(t, err, ErrMissingRequestingOrg) + assert.Equal(t, 0, ambient.calls, "ambient provider must not be hit when org is missing") + }) +} + +// countingCredsProvider is the minimum aws.CredentialsProvider needed to +// observe whether the dev-mode short-circuit invoked it. Used in the +// dev-mode test above and nowhere else. +type countingCredsProvider struct { + creds aws.Credentials + calls int +} + +func (c *countingCredsProvider) Retrieve(_ context.Context) (aws.Credentials, error) { + c.calls++ + return c.creds, nil +} + // --- helpers ----------------------------------------------------------- // newTestBackend constructs a fully wired *Backend that uses static dummy @@ -131,7 +182,6 @@ func newTestBackend(t *testing.T) *Backend { t.Helper() return backendForCreds(t, &Credentials{ AccessPointARN: "arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org-abc", - KeyPrefix: "org/abc", }) } diff --git a/pkg/blobmanager/s3accesspoint/provider.go b/pkg/blobmanager/s3accesspoint/provider.go index 266decf6d..1c99b1a63 100644 --- a/pkg/blobmanager/s3accesspoint/provider.go +++ b/pkg/blobmanager/s3accesspoint/provider.go @@ -25,9 +25,12 @@ // resource policy enforces a StringEquals on aws:userid so that a // session minted for org A cannot read or write to org B's AP — even if // org A's secret blob has been tampered with to point at org B's ARN. -// 3. A KeyPrefix that namespaces every object under /sha256: -// and is also referenced in the session policy's Resource field, so that -// no tenant's request can address keys outside its own prefix. +// 3. A per-tenant key prefix derived from the requesting org UUID: every +// object is keyed as /sha256: and the AssumeRole +// session policy's Resource is scoped to ${apARN}/object//*. +// The prefix shares its source of truth with the session name, so a +// tampered secret cannot reroute a tenant's writes into a different +// namespace. // // The session name MUST come from the request context, not from the secret // blob: a secrets-store compromise alone must not let an attacker reroute @@ -39,6 +42,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "strings" "time" @@ -67,25 +71,48 @@ type Config struct { // allow s3:{Get,Put,Delete,Head}Object against every access point in // the account; the per-call session policy narrows that down to one // AP + one prefix. + // + // Required in production. Ignored (and may be empty) when + // DevModeUseAmbientCredentials is true. BaseRoleARN string // Region is the default region for the underlying bucket and the // access points. Individual managed rows may override this via // Credentials.Region. Region string // SessionDuration is the STS token lifetime. Defaults to - // DefaultSessionDuration when zero. + // DefaultSessionDuration when zero. Ignored when + // DevModeUseAmbientCredentials is true. SessionDuration time.Duration + + // DevModeUseAmbientCredentials short-circuits sts:AssumeRole and + // routes S3 calls through whatever ambient AWS identity the SDK's + // default credential chain produced (env vars, ~/.aws/credentials, + // instance profile, IRSA, …). The fail-closed check on a missing + // requesting-org context is still enforced so callers that forget + // WithRequestingOrg get the same error locally as they would in + // production. + // + // DEV ONLY. This bypasses the per-tenant isolation guarantees that + // the AssumeRole + session-policy + AP-policy chain provides; objects + // addressed via this backend are limited only by whatever the + // developer's IAM identity allows. NEVER set this in a multi-tenant + // deployment. + DevModeUseAmbientCredentials bool } func (c *Config) Validate() error { if c == nil { return errors.New("s3accesspoint: nil config") } - if c.BaseRoleARN == "" { - return errors.New("s3accesspoint: base_role_arn is required") - } - if !strings.HasPrefix(c.BaseRoleARN, "arn:aws:iam::") { - return fmt.Errorf("s3accesspoint: base_role_arn %q is not a valid IAM role ARN", c.BaseRoleARN) + // Base role is only required when we actually plan to assume it. + // In dev mode the SDK's default credential chain stands in for it. + if !c.DevModeUseAmbientCredentials { + if c.BaseRoleARN == "" { + return errors.New("s3accesspoint: base_role_arn is required (or set dev_mode_use_ambient_credentials in dev)") + } + if !strings.HasPrefix(c.BaseRoleARN, "arn:aws:iam::") { + return fmt.Errorf("s3accesspoint: base_role_arn %q is not a valid IAM role ARN", c.BaseRoleARN) + } } if c.Region == "" { return errors.New("s3accesspoint: region is required") @@ -97,6 +124,14 @@ func (c *Config) Validate() error { // CASBackend.SecretName. Despite the name it carries no access keys — only // tenant-identifying coordinates used to construct a scoped S3 client. // +// The per-tenant key prefix is intentionally NOT a field here: it's +// derived at request time from the authenticated requesting org carried +// in ctx via WithRequestingOrg. Both the bucket-layer key namespace and +// the AssumeRole session-name binding therefore come from the same +// untamperable source, so a secrets-store compromise that rewrites this +// blob still can't reroute a tenant's writes into another tenant's +// namespace. +// // The platform reconciler is responsible for writing this blob in lockstep // with the AWS-side AP creation and policy. type Credentials struct { @@ -108,12 +143,6 @@ type Credentials struct { // Region overrides Config.Region for this tenant. Optional; useful if // the deployment grows multi-region without rolling a new config. Region string - // KeyPrefix is the per-tenant key namespace inside the underlying - // bucket, e.g. "org/". The provider keys every object under - // "/sha256:" and the session policy's Resource is - // scoped to "${apARN}/object/${KeyPrefix}/*". The AP's resource policy - // is expected to enforce the same prefix via an s3:prefix condition. - KeyPrefix string } func (c *Credentials) Validate() error { @@ -126,12 +155,6 @@ func (c *Credentials) Validate() error { if !strings.HasPrefix(c.AccessPointARN, "arn:aws:s3:") || !strings.Contains(c.AccessPointARN, ":accesspoint/") { return fmt.Errorf("%w: access_point_arn %q is not an S3 access point ARN", backend.ErrValidation, c.AccessPointARN) } - if c.KeyPrefix == "" { - return fmt.Errorf("%w: missing key_prefix", backend.ErrValidation) - } - if strings.HasPrefix(c.KeyPrefix, "/") || strings.HasSuffix(c.KeyPrefix, "/") { - return fmt.Errorf("%w: key_prefix %q must not start or end with '/'", backend.ErrValidation, c.KeyPrefix) - } return nil } @@ -161,6 +184,12 @@ func NewBackendProvider(cfg *Config, cReader credentials.Reader) (*BackendProvid if cfg.SessionDuration == 0 { cfg.SessionDuration = DefaultSessionDuration } + // Loud warning at startup so misconfiguration is obvious in logs. We + // use the std log here because the kratos logger isn't plumbed down + // to this package by design — keeping the provider portable. + if cfg.DevModeUseAmbientCredentials { + log.Printf("WARNING: s3accesspoint provider configured with DevModeUseAmbientCredentials=true; sts:AssumeRole is bypassed and per-tenant isolation is NOT enforced — DEV USE ONLY") + } return &BackendProvider{cfg: cfg, cReader: cReader}, nil } diff --git a/pkg/blobmanager/s3accesspoint/provider_test.go b/pkg/blobmanager/s3accesspoint/provider_test.go index 54330f16a..d24ebb4e4 100644 --- a/pkg/blobmanager/s3accesspoint/provider_test.go +++ b/pkg/blobmanager/s3accesspoint/provider_test.go @@ -32,7 +32,6 @@ func validCreds() Credentials { return Credentials{ AccessPointARN: "arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org-abc", Region: "us-east-1", - KeyPrefix: "org/abc", } } @@ -79,21 +78,6 @@ func TestCredentials_Validate(t *testing.T) { mutate: func(c *Credentials) { c.AccessPointARN = "arn:aws:s3:::some-bucket" }, wantErr: "not an S3 access point ARN", }, - { - name: "missing prefix", - mutate: func(c *Credentials) { c.KeyPrefix = "" }, - wantErr: "missing key_prefix", - }, - { - name: "prefix leading slash", - mutate: func(c *Credentials) { c.KeyPrefix = "/org/abc" }, - wantErr: "must not start or end with", - }, - { - name: "prefix trailing slash", - mutate: func(c *Credentials) { c.KeyPrefix = "org/abc/" }, - wantErr: "must not start or end with", - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -145,7 +129,7 @@ func TestValidateAndExtractCredentials(t *testing.T) { creds, ok := out.(*Credentials) require.True(t, ok, "expected *Credentials, got %T", out) assert.Equal(t, good.AccessPointARN, creds.AccessPointARN) - assert.Equal(t, good.KeyPrefix, creds.KeyPrefix) + assert.Equal(t, good.Region, creds.Region) }) } } @@ -191,6 +175,25 @@ func TestNewBackendProvider_FailsOnBadConfig(t *testing.T) { assert.ErrorContains(t, err, "credentials reader is required") } +// Dev mode relaxes the base_role_arn requirement because nothing on the +// hot path will actually call sts:AssumeRole. Region is still required — +// the SDK config needs it to construct any S3 client at all. +func TestConfig_Validate_DevModeRelaxesBaseRoleARN(t *testing.T) { + t.Parallel() + + // Without dev mode: empty base role rejected. + err := (&Config{Region: "us-east-1"}).Validate() + require.ErrorContains(t, err, "base_role_arn is required") + + // With dev mode: empty base role accepted. + err = (&Config{Region: "us-east-1", DevModeUseAmbientCredentials: true}).Validate() + require.NoError(t, err) + + // Region is still mandatory in dev mode. + err = (&Config{DevModeUseAmbientCredentials: true}).Validate() + require.ErrorContains(t, err, "region is required") +} + // stubReader is the minimal credentials.Reader implementation needed to // exercise constructor wiring; the unit tests never invoke it. type stubReader struct{} From 99ba5ee3bbe89da06636bec38c654f8643b43061 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 15 May 2026 20:21:54 +0200 Subject: [PATCH 03/18] feat(casbackend): redact AP ARN and provider ID from managed-backend wire output For Managed=true CAS backends, replace Location with "managed by Chainloop" and Provider with "Chainloop" everywhere the controlplane emits a CASBackend outside its trust boundary: * API responses (bizCASBackendToPb), so `chainloop cas-backend ls` no longer prints the AWS account ID, region, or AP name. * Audit-log events on the NATS bus (CASBackendCreated, CASBackendUpdated, CASBackendDeleted, CASBackendPermanentDeleted, CASBackendStatusChanged), so downstream consumers can't surface the same details to tenants either. The DB and biz layer continue to carry the real ARN and provider ID unchanged, so PerformValidation, the platform reconciler, and any forensic join by CASBackendID still work. Two helpers (displayLocation, displayProvider) keep the sanitization rule in one place. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- .../internal/service/casbackend.go | 16 ++++- .../internal/service/casbackend_test.go | 69 +++++++++++++++++++ app/controlplane/pkg/biz/casbackend.go | 66 +++++++++++++++--- 3 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 app/controlplane/internal/service/casbackend_test.go diff --git a/app/controlplane/internal/service/casbackend.go b/app/controlplane/internal/service/casbackend.go index 8763e3c3b..1f2c62e24 100644 --- a/app/controlplane/internal/service/casbackend.go +++ b/app/controlplane/internal/service/casbackend.go @@ -193,13 +193,25 @@ func (s *CASBackendService) Revalidate(ctx context.Context, req *pb.CASBackendSe } func bizCASBackendToPb(in *biz.CASBackend) *pb.CASBackendItem { + // Managed backends hide both Location (AP ARN) and Provider + // (AWS-S3-ACCESS-POINT) from API clients — both are implementation + // details that tenants don't need to know. The DB and biz layer + // still carry the real values; only the wire format is sanitized. + // See biz.CASBackendManagedLocationDisplay / + // biz.CASBackendManagedProviderDisplay. + location := in.Location + provider := string(in.Provider) + if in.Managed { + location = biz.CASBackendManagedLocationDisplay + provider = biz.CASBackendManagedProviderDisplay + } r := &pb.CASBackendItem{ - Id: in.ID.String(), Location: in.Location, Description: in.Description, + Id: in.ID.String(), Location: location, Description: in.Description, Name: in.Name, CreatedAt: timestamppb.New(*in.CreatedAt), UpdatedAt: timestamppb.New(*in.UpdatedAt), ValidatedAt: timestamppb.New(*in.ValidatedAt), - Provider: string(in.Provider), + Provider: provider, Default: in.Default, Fallback: in.Fallback, IsInline: in.Inline, diff --git a/app/controlplane/internal/service/casbackend_test.go b/app/controlplane/internal/service/casbackend_test.go new file mode 100644 index 000000000..2e2992210 --- /dev/null +++ b/app/controlplane/internal/service/casbackend_test.go @@ -0,0 +1,69 @@ +// +// 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" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +// TestBizCASBackendToPb_HidesManagedDetails guards the rule that +// managed-backend implementation details (AP ARN, provider ID) never +// leak to API clients. Non-managed rows keep their original Location +// and Provider; managed rows are rewritten to stable placeholders. +// Regression-prevention only — both fields are otherwise straightforward +// to map. +func TestBizCASBackendToPb_HidesManagedDetails(t *testing.T) { + now := time.Now() + realLocation := "arn:aws:s3:us-east-1:471112941097:accesspoint/chainloop-org-dev" + realProvider := biz.CASBackendProvider("AWS-S3-ACCESS-POINT") + + base := biz.CASBackend{ + ID: uuid.New(), + Name: "backend", + Location: realLocation, + CreatedAt: &now, + UpdatedAt: &now, + ValidatedAt: &now, + Provider: realProvider, + } + + t.Run("non-managed exposes location and provider verbatim", func(t *testing.T) { + in := base + in.Managed = false + got := bizCASBackendToPb(&in) + assert.Equal(t, realLocation, got.Location, + "non-managed rows must surface their real location") + assert.Equal(t, string(realProvider), got.Provider, + "non-managed rows must surface their real provider") + assert.False(t, got.IsManaged) + }) + + t.Run("managed replaces location and provider with placeholders", func(t *testing.T) { + in := base + in.Managed = true + got := bizCASBackendToPb(&in) + assert.Equal(t, biz.CASBackendManagedLocationDisplay, got.Location, + "managed rows must never leak the underlying AP ARN") + assert.Equal(t, biz.CASBackendManagedProviderDisplay, got.Provider, + "managed rows must never leak the backing provider ID") + assert.True(t, got.IsManaged) + }) +} diff --git a/app/controlplane/pkg/biz/casbackend.go b/app/controlplane/pkg/biz/casbackend.go index 007fa79f7..508b0694f 100644 --- a/app/controlplane/pkg/biz/casbackend.go +++ b/app/controlplane/pkg/biz/casbackend.go @@ -48,8 +48,54 @@ const ( MinCASBackendMaxBytes int64 = 10 * 1024 * 1024 // 10MB minimum errMsgCredentialsAccess = "Failed to access CAS backend credentials in external Secrets Manager" errMsgCredentialsFormat = "Invalid CAS backend credentials format from external Secrets Manager" + + // CASBackendManagedLocationDisplay is the placeholder substituted for + // CASBackend.Location whenever a managed backend is exposed beyond + // the controlplane's trust boundary (API responses, audit events). + // The real ARN remains in the DB so PerformValidation, the platform + // reconciler, and forensic joins by CASBackendID still work — only + // the wire-format Location is sanitized. + CASBackendManagedLocationDisplay = "managed by Chainloop" + + // CASBackendManagedProviderDisplay is the placeholder substituted + // for CASBackend.Provider on managed backends. The underlying + // provider ID ("AWS-S3-ACCESS-POINT" today, possibly other managed + // providers tomorrow) is itself an implementation detail that + // tenants shouldn't see; "Chainloop" tells them everything they + // need to know about ownership without revealing the backing + // technology. + CASBackendManagedProviderDisplay = "Chainloop" ) +// displayLocation returns the location string we expose outside the +// controlplane's trust boundary. Managed backends get a stable +// placeholder; everything else passes through verbatim. Use this for +// any path that emits a CASBackend.Location to API clients or to the +// audit event bus. +func displayLocation(b *CASBackend) string { + if b != nil && b.Managed { + return CASBackendManagedLocationDisplay + } + if b == nil { + return "" + } + return b.Location +} + +// displayProvider returns the provider string we expose outside the +// controlplane's trust boundary. Managed backends report a generic +// "Chainloop" provider name so the specific backing technology stays +// internal. Non-managed backends pass through their provider ID. +func displayProvider(b *CASBackend) string { + if b == nil { + return "" + } + if b.Managed { + return CASBackendManagedProviderDisplay + } + return string(b.Provider) +} + var CASBackendInlineDescription = "Embed artifacts content in the attestation (fallback)" type CASBackendValidationStatus string @@ -411,8 +457,8 @@ func (uc *CASBackendUseCase) Create(ctx context.Context, orgID, name, location, CASBackendBase: &events.CASBackendBase{ CASBackendID: &backend.ID, CASBackendName: backend.Name, - Provider: string(backend.Provider), - Location: backend.Location, + Provider: displayProvider(backend), + Location: displayLocation(backend), Default: backend.Default, }, CASBackendDescription: description, @@ -533,8 +579,8 @@ func (uc *CASBackendUseCase) Update(ctx context.Context, orgID, id string, descr CASBackendBase: &events.CASBackendBase{ CASBackendID: &after.ID, CASBackendName: after.Name, - Provider: string(after.Provider), - Location: after.Location, + Provider: displayProvider(after), + Location: displayLocation(after), Default: after.Default, }, NewDescription: description, @@ -643,8 +689,8 @@ func (uc *CASBackendUseCase) SoftDelete(ctx context.Context, orgID, id string) e CASBackendBase: &events.CASBackendBase{ CASBackendID: &backend.ID, CASBackendName: backend.Name, - Provider: string(backend.Provider), - Location: backend.Location, + Provider: displayProvider(backend), + Location: displayLocation(backend), Default: backend.Default, }, }, &orgUUID) @@ -692,8 +738,8 @@ func (uc *CASBackendUseCase) Delete(ctx context.Context, id string) error { CASBackendBase: &events.CASBackendBase{ CASBackendID: &backend.ID, CASBackendName: backend.Name, - Provider: string(backend.Provider), - Location: backend.Location, + Provider: displayProvider(backend), + Location: displayLocation(backend), Default: backend.Default, }, }, &backend.OrganizationID) @@ -782,8 +828,8 @@ func (uc *CASBackendUseCase) PerformValidation(ctx context.Context, id string) e CASBackendBase: &events.CASBackendBase{ CASBackendID: &backend.ID, CASBackendName: backend.Name, - Provider: string(backend.Provider), - Location: backend.Location, + Provider: displayProvider(backend), + Location: displayLocation(backend), Default: backend.Default, }, PreviousStatus: string(previousStatus), From 558f97c22dac2d0e753743f1686e93f504942ae9 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 15 May 2026 21:29:07 +0200 Subject: [PATCH 04/18] fix(casbackend): tighten managed-CAS org scoping per PR review Two follow-ups from the PR review on #3121: * The CAS JWT minted by cascredential.go, attestation.go and casredirect.go now embeds OrgID from the authenticated caller (entities.CurrentOrg / robotAccount.OrgID) instead of backend.OrganizationID. For managed S3 Access Point backends this OrgID drives the AssumeRole session name and the AP-policy aws:userid match; deriving it from the resolved row would weaken the cross-tenant guarantee if a future bug ever let a caller resolve a backend they don't own. * The S3AccessPoint proto message now carries a buf.validate CEL constraint that requires base_role_arn when dev_mode_use_ambient_credentials is false, surfacing the misconfiguration at config-load time rather than at first upload. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- .../conf/controlplane/config/v1/conf.pb.go | 10 ++++++---- .../conf/controlplane/config/v1/conf.proto | 14 +++++++++++++- app/controlplane/internal/service/attestation.go | 8 +++++++- app/controlplane/internal/service/cascredential.go | 7 ++++++- app/controlplane/internal/service/casredirect.go | 7 ++++++- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go index 8e210dab4..ab9920a22 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go @@ -1348,7 +1348,8 @@ type BlobBackends_S3AccessPoint struct { // IAM role the controlplane / artifact-cas pod assumes per request // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every // access point in the account. Required in production; may be empty - // when dev_mode_use_ambient_credentials is true. + // when dev_mode_use_ambient_credentials is true (see CEL constraint + // above). BaseRoleArn string `protobuf:"bytes,1,opt,name=base_role_arn,json=baseRoleArn,proto3" json:"base_role_arn,omitempty"` // Default AWS region for the underlying bucket and access points. // Individual managed CASBackend rows can override per-tenant. @@ -1983,14 +1984,15 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x03uri\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x03uri\x12\x1f\n" + "\x05token\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x05token\x12\x1a\n" + "\breplicas\x18\x03 \x01(\x05R\breplicasB\x10\n" + - "\x0eauthentication\"\xcf\x02\n" + + "\x0eauthentication\"\x9a\x04\n" + "\fBlobBackends\x12Z\n" + - "\x0fs3_access_point\x18\x01 \x01(\v22.controlplane.config.v1.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\xe2\x01\n" + + "\x0fs3_access_point\x18\x01 \x01(\v22.controlplane.config.v1.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\xad\x03\n" + "\rS3AccessPoint\x12\"\n" + "\rbase_role_arn\x18\x01 \x01(\tR\vbaseRoleArn\x12\x1f\n" + "\x06region\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06region\x12D\n" + "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\x12F\n" + - " dev_mode_use_ambient_credentials\x18\x04 \x01(\bR\x1cdevModeUseAmbientCredentials\"6\n" + + " dev_mode_use_ambient_credentials\x18\x04 \x01(\bR\x1cdevModeUseAmbientCredentials:\xc8\x01\xbaH\xc4\x01\x1a\xc1\x01\n" + + ".s3_access_point.base_role_arn_required_in_prod\x12Hbase_role_arn is required when dev_mode_use_ambient_credentials is false\x1aEthis.dev_mode_use_ambient_credentials || size(this.base_role_arn) > 0\"6\n" + "\fAttestations\x12&\n" + "\x0fskip_db_storage\x18\x01 \x01(\bR\rskipDbStorage\"V\n" + "\x1eOperationAuthorizationProvider\x12\x1a\n" + diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto index c6d3c458f..653255c2c 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto @@ -145,10 +145,22 @@ message BlobBackends { S3AccessPoint s3_access_point = 1; message S3AccessPoint { + // base_role_arn is conditionally required: production deployments + // (dev_mode_use_ambient_credentials=false) must specify a role to + // assume; dev-mode deployments may leave it empty. Enforced at + // config-load time rather than only at first upload so bad config + // surfaces immediately. + option (buf.validate.message).cel = { + id: "s3_access_point.base_role_arn_required_in_prod" + message: "base_role_arn is required when dev_mode_use_ambient_credentials is false" + expression: "this.dev_mode_use_ambient_credentials || size(this.base_role_arn) > 0" + }; + // IAM role the controlplane / artifact-cas pod assumes per request // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every // access point in the account. Required in production; may be empty - // when dev_mode_use_ambient_credentials is true. + // when dev_mode_use_ambient_credentials is true (see CEL constraint + // above). string base_role_arn = 1; // Default AWS region for the underlying bucket and access points. // Individual managed CASBackend rows can override per-tenant. diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index 2eb29fee5..8970a602e 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -494,7 +494,13 @@ func (s *AttestationService) GetUploadCreds(ctx context.Context, req *cpAPI.Atte // Return the backend information and associated credentials (if applicable) resp := &cpAPI.AttestationServiceGetUploadCredsResponse_Result{Backend: bizCASBackendToPb(backend)} if backend.SecretName != "" { - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID.String()} + // OrgID comes from the authenticated robot account (the caller), + // not the backend's owner. Even though GetByIDInOrgOrPublic above + // already scopes the lookup to robotAccount.OrgID, the security + // invariant for managed CAS requires the JWT org-id claim to + // originate from the caller's identity rather than the row we + // happened to resolve. + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes, OrgID: robotAccount.OrgID} t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/cascredential.go b/app/controlplane/internal/service/cascredential.go index 436122e5a..008727976 100644 --- a/app/controlplane/internal/service/cascredential.go +++ b/app/controlplane/internal/service/cascredential.go @@ -149,7 +149,12 @@ 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.String()} + // OrgID MUST come from the authenticated caller, not the resolved backend. + // For managed CAS the JWT's org-id claim drives the AssumeRole session + // name and AP-policy aws:userid match; pulling it from backend.OrganizationID + // would short-circuit that check against the very row a tampered secret + // could redirect. + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: currentOrg.ID} t, err := s.casUC.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/casredirect.go b/app/controlplane/internal/service/casredirect.go index 74d6ec437..78a250a8c 100644 --- a/app/controlplane/internal/service/casredirect.go +++ b/app/controlplane/internal/service/casredirect.go @@ -126,7 +126,12 @@ func (s *CASRedirectService) GetDownloadURL(ctx context.Context, req *pb.GetDown // 2- add authentication token to the query params ?t=[token] if backend.SecretName != "" { - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID.String()} + // OrgID comes from the authenticated caller (currentOrg from + // ctx), not the resolved backend. For managed CAS this is what + // keys the AssumeRole session name and the AP-policy aws:userid + // match; deriving it from backend.OrganizationID would weaken + // the cross-tenant guarantee. + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes, OrgID: currentOrg.ID} t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) From 7457ed2b601025ba1cc1d7a6bddb76954c66c374 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 15 May 2026 21:29:22 +0200 Subject: [PATCH 05/18] fix(deps): restore go.mod/go.sum to main baseline A `go mod tidy` while developing the s3accesspoint provider regressed several deps: * go-git/v6 downgraded alpha.3 -> alpha.2 (CVE-2026-45022, commit signature spoofing) * go-billy/v5 downgraded 5.9.0 -> 5.8.0 (CVE-2026-44973 path traversal, CVE-2026-44740 symlink-loop DoS) * go-billy/v6 swapped to an older snapshot * go-git/v5 downgraded 5.19.0 -> 5.18.0 * unrelated olekukonko/* and golang.org/x/* version churn that broke CI's go-module tidy check Restoring go.mod and go.sum to match origin/main resolves both the Kusari CVE alerts and the CI failures. aws-sdk-go-v2/service/sts (needed by the s3accesspoint provider) is already an indirect at v1.41.9 on main, so no go.mod change is required for the new code to build. Assisted-by: Claude Code Signed-off-by: Jose I. Paris --- go.mod | 18 +++++++++--------- go.sum | 46 ++++++++++++++++++++-------------------------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/go.mod b/go.mod index 815c2f62f..e1e183342 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,7 @@ require ( github.com/testcontainers/testcontainers-go v0.40.0 go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.1 - golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.42.0 google.golang.org/api v0.272.0 @@ -72,7 +72,7 @@ require ( github.com/casbin/casbin/v2 v2.103.0 github.com/denisbrodbeck/machineid v1.0.1 github.com/extism/go-sdk v1.7.1 - github.com/go-git/go-git/v6 v6.0.0-alpha.2 // recommended path: https://github.com/go-git/go-git/issues/1943#issuecomment-4232656963 + github.com/go-git/go-git/v6 v6.0.0-alpha.3 // recommended path: https://github.com/go-git/go-git/issues/1943#issuecomment-4232656963 github.com/google/go-github/v66 v66.0.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 @@ -193,8 +193,8 @@ require ( github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect - github.com/go-git/go-billy/v5 v5.8.0 // indirect - github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-billy/v6 v6.0.0-alpha.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -268,7 +268,7 @@ require ( github.com/package-url/packageurl-go v0.1.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect - github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/xattr v0.4.9 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect @@ -366,7 +366,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsouza/fake-gcs-server v1.47.6 - github.com/go-git/go-git/v5 v5.18.0 // indirect + github.com/go-git/go-git/v5 v5.19.0 // indirect github.com/go-kratos/aegis v0.2.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -454,13 +454,13 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.50.0 - golang.org/x/mod v0.34.0 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 // indirect + golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.1 // indirect diff --git a/go.sum b/go.sum index 4dc6e3b81..e6fba39bf 100644 --- a/go.sum +++ b/go.sum @@ -447,19 +447,19 @@ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= -github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= -github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= -github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d h1:bLMI9z4mKkfQO383+O3fkP4xdWQcMdnn5fFBMwaBC1M= -github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d/go.mod h1:LLeMBFApkgIKwMzirxpU9XB7NvO2HdTw5FXmeP1M6c8= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc= -github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= +github.com/go-git/go-git-fixtures/v6 v6.0.0-20260422085740-0c07409f52ec h1:FpCNUs50xfQyJJs31uO3mDnqU855OhzAzfkkTgE6/DI= +github.com/go-git/go-git-fixtures/v6 v6.0.0-20260422085740-0c07409f52ec/go.mod h1:F1SpxOny2UYXu62DzjEH4UqBjk4AoGs27cA8I9buK+o= github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= -github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= -github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= -github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI= -github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= +github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc= +github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.3 h1:lJGritJ5AcC0X7buV0lReZ4cEHqcKB3Ab2ZjD3Ku+Ss= +github.com/go-git/go-git/v6 v6.0.0-alpha.3/go.mod h1:DGnqu+twdAgtDx/4tQTWFrVE1an+2ACph3W9yOfSJZM= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -1024,13 +1024,7 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= -github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= -github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= -github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= -github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= @@ -1114,8 +1108,8 @@ github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFu github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= -github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= -github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -1528,8 +1522,8 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -1558,8 +1552,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1708,8 +1702,8 @@ golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1794,8 +1788,8 @@ golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 0875185e07b3191cc77580431534ab0f4bb8e993 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 12:23:09 +0200 Subject: [PATCH 06/18] refactor(conf): rename BlobBackends proto message to ManagedCASBackends The proto message and its YAML field describe configuration for *managed* CAS backends (provisioned and operated by Chainloop), not generic blob storage. Rename: * proto message `BlobBackends` -> `ManagedCASBackends` * proto field `blob_backends` -> `managed_cas_backends` in both controlplane and artifact-cas Bootstrap messages * matching Go field on the regenerated `*conf.Bootstrap` (`ManagedCasBackends`) and references in wire.go / wire_gen.go * commented-out example block in both `config.devel.yaml` No behavioural change; the only deployments that read this block today are local-dev configs (gitignored config.local.yaml) which have been updated separately. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- app/artifact-cas/cmd/wire.go | 12 +- app/artifact-cas/cmd/wire_gen.go | 14 +- app/artifact-cas/configs/config.devel.yaml | 8 +- app/artifact-cas/internal/conf/conf.pb.go | 114 +++++++------- app/artifact-cas/internal/conf/conf.proto | 16 +- app/controlplane/cmd/wire.go | 12 +- app/controlplane/cmd/wire_gen.go | 14 +- app/controlplane/configs/config.devel.yaml | 2 +- .../conf/controlplane/config/v1/conf.pb.go | 144 +++++++++--------- .../conf/controlplane/config/v1/conf.proto | 16 +- 10 files changed, 178 insertions(+), 174 deletions(-) diff --git a/app/artifact-cas/cmd/wire.go b/app/artifact-cas/cmd/wire.go index 61929164f..b9f2f670e 100644 --- a/app/artifact-cas/cmd/wire.go +++ b/app/artifact-cas/cmd/wire.go @@ -39,7 +39,7 @@ func wireApp(*conf.Bootstrap, *conf.Server, *conf.Auth, credentials.Reader, log. service.ProviderSet, loader.LoadProviders, newLoaderOptions, - wire.FieldsOf(new(*conf.Bootstrap), "BlobBackends"), + wire.FieldsOf(new(*conf.Bootstrap), "ManagedCasBackends"), newApp, serviceOpts, newProtoValidator, @@ -48,11 +48,11 @@ func wireApp(*conf.Bootstrap, *conf.Server, *conf.Auth, credentials.Reader, log. } // newLoaderOptions builds the loader.Options struct from the deployment -// Bootstrap. When `blob_backends.s3_access_point` is absent (the common -// case for on-prem) S3AccessPoint stays nil and the provider is not -// registered, leaving the binary's behaviour identical to the pre-managed -// CAS world. -func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { +// Bootstrap. When `managed_cas_backends.s3_access_point` is absent (the +// common case for on-prem) S3AccessPoint stays nil and the provider is +// not registered, leaving the binary's behaviour identical to the +// pre-managed-CAS world. +func newLoaderOptions(in *conf.ManagedCASBackends, l log.Logger) *loader.Options { opts := &loader.Options{Logger: l} if in == nil || in.GetS3AccessPoint() == nil { return opts diff --git a/app/artifact-cas/cmd/wire_gen.go b/app/artifact-cas/cmd/wire_gen.go index 20339454c..ddd9e0061 100644 --- a/app/artifact-cas/cmd/wire_gen.go +++ b/app/artifact-cas/cmd/wire_gen.go @@ -24,8 +24,8 @@ import ( // wireApp init kratos application. func wireApp(bootstrap *conf.Bootstrap, confServer *conf.Server, auth *conf.Auth, reader credentials.Reader, logger log.Logger) (*app, func(), error) { - blobBackends := bootstrap.BlobBackends - options := newLoaderOptions(blobBackends, logger) + managedCASBackends := bootstrap.ManagedCasBackends + options := newLoaderOptions(managedCASBackends, logger) providers := loader.LoadProviders(reader, options) v := serviceOpts(logger) byteStreamService := service.NewByteStreamService(providers, v...) @@ -60,11 +60,11 @@ func wireApp(bootstrap *conf.Bootstrap, confServer *conf.Server, auth *conf.Auth // wire.go: // newLoaderOptions builds the loader.Options struct from the deployment -// Bootstrap. When `blob_backends.s3_access_point` is absent (the common -// case for on-prem) S3AccessPoint stays nil and the provider is not -// registered, leaving the binary's behaviour identical to the pre-managed -// CAS world. -func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { +// Bootstrap. When `managed_cas_backends.s3_access_point` is absent (the +// common case for on-prem) S3AccessPoint stays nil and the provider is +// not registered, leaving the binary's behaviour identical to the +// pre-managed-CAS world. +func newLoaderOptions(in *conf.ManagedCASBackends, l log.Logger) *loader.Options { opts := &loader.Options{Logger: l} if in == nil || in.GetS3AccessPoint() == nil { return opts diff --git a/app/artifact-cas/configs/config.devel.yaml b/app/artifact-cas/configs/config.devel.yaml index 11b477ff2..e827bee25 100644 --- a/app/artifact-cas/configs/config.devel.yaml +++ b/app/artifact-cas/configs/config.devel.yaml @@ -41,10 +41,10 @@ auth: public_key_path: ${PUBLIC_KEY_PATH:../../devel/devkeys/cas.pub} # Optional managed CAS provider (S3 Access Points). Mirrors the -# controlplane's blob_backends block — both binaries must agree on the -# settings since each independently instantiates the provider. Leave -# commented out for on-prem deployments that don't use managed CAS. -# blob_backends: +# controlplane's managed_cas_backends block — both binaries must agree +# on the settings since each independently instantiates the provider. +# Leave commented out for on-prem deployments that don't use managed CAS. +# managed_cas_backends: # s3_access_point: # base_role_arn: arn:aws:iam::123456789012:role/chainloop-cas-tenant # region: us-east-1 diff --git a/app/artifact-cas/internal/conf/conf.pb.go b/app/artifact-cas/internal/conf/conf.pb.go index 0922d0b25..ffe0ff585 100644 --- a/app/artifact-cas/internal/conf/conf.pb.go +++ b/app/artifact-cas/internal/conf/conf.pb.go @@ -44,12 +44,12 @@ type Bootstrap struct { Auth *Auth `protobuf:"bytes,2,opt,name=auth,proto3" json:"auth,omitempty"` Observability *Bootstrap_Observability `protobuf:"bytes,3,opt,name=observability,proto3" json:"observability,omitempty"` CredentialsService *v1.Credentials `protobuf:"bytes,4,opt,name=credentials_service,json=credentialsService,proto3" json:"credentials_service,omitempty"` - // Deployment-level configuration for storage backend providers that - // need ambient knobs beyond what's stored per-CASBackend. Optional — + // Deployment-level configuration for managed CAS storage backends + // (provisioned and operated by Chainloop, not by tenants). Optional — // omitting a sub-block keeps the corresponding provider unregistered. - BlobBackends *BlobBackends `protobuf:"bytes,5,opt,name=blob_backends,json=blobBackends,proto3" json:"blob_backends,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ManagedCasBackends *ManagedCASBackends `protobuf:"bytes,5,opt,name=managed_cas_backends,json=managedCasBackends,proto3" json:"managed_cas_backends,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Bootstrap) Reset() { @@ -110,9 +110,9 @@ func (x *Bootstrap) GetCredentialsService() *v1.Credentials { return nil } -func (x *Bootstrap) GetBlobBackends() *BlobBackends { +func (x *Bootstrap) GetManagedCasBackends() *ManagedCASBackends { if x != nil { - return x.BlobBackends + return x.ManagedCasBackends } return nil } @@ -180,31 +180,31 @@ func (x *Server) GetHttpMetrics() *Server_HTTP { return nil } -// BlobBackends mirrors the controlplane's `BlobBackends` block. Defined -// independently here so the artifact-cas binary doesn't depend on the -// controlplane's protobuf package. Keep field numbering in sync across -// both definitions. -type BlobBackends struct { - state protoimpl.MessageState `protogen:"open.v1"` - S3AccessPoint *BlobBackends_S3AccessPoint `protobuf:"bytes,1,opt,name=s3_access_point,json=s3AccessPoint,proto3" json:"s3_access_point,omitempty"` +// ManagedCASBackends mirrors the controlplane's `ManagedCASBackends` +// block. Defined independently here so the artifact-cas binary doesn't +// depend on the controlplane's protobuf package. Keep field numbering +// in sync across both definitions. +type ManagedCASBackends struct { + state protoimpl.MessageState `protogen:"open.v1"` + S3AccessPoint *ManagedCASBackends_S3AccessPoint `protobuf:"bytes,1,opt,name=s3_access_point,json=s3AccessPoint,proto3" json:"s3_access_point,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *BlobBackends) Reset() { - *x = BlobBackends{} +func (x *ManagedCASBackends) Reset() { + *x = ManagedCASBackends{} mi := &file_conf_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *BlobBackends) String() string { +func (x *ManagedCASBackends) String() string { return protoimpl.X.MessageStringOf(x) } -func (*BlobBackends) ProtoMessage() {} +func (*ManagedCASBackends) ProtoMessage() {} -func (x *BlobBackends) ProtoReflect() protoreflect.Message { +func (x *ManagedCASBackends) ProtoReflect() protoreflect.Message { mi := &file_conf_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -216,12 +216,12 @@ func (x *BlobBackends) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use BlobBackends.ProtoReflect.Descriptor instead. -func (*BlobBackends) Descriptor() ([]byte, []int) { +// Deprecated: Use ManagedCASBackends.ProtoReflect.Descriptor instead. +func (*ManagedCASBackends) Descriptor() ([]byte, []int) { return file_conf_proto_rawDescGZIP(), []int{2} } -func (x *BlobBackends) GetS3AccessPoint() *BlobBackends_S3AccessPoint { +func (x *ManagedCASBackends) GetS3AccessPoint() *ManagedCASBackends_S3AccessPoint { if x != nil { return x.S3AccessPoint } @@ -698,7 +698,7 @@ func (x *Server_GRPC) GetTlsConfig() *Server_TLS { return nil } -type BlobBackends_S3AccessPoint struct { +type ManagedCASBackends_S3AccessPoint struct { state protoimpl.MessageState `protogen:"open.v1"` BaseRoleArn string `protobuf:"bytes,1,opt,name=base_role_arn,json=baseRoleArn,proto3" json:"base_role_arn,omitempty"` Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` @@ -710,20 +710,20 @@ type BlobBackends_S3AccessPoint struct { sizeCache protoimpl.SizeCache } -func (x *BlobBackends_S3AccessPoint) Reset() { - *x = BlobBackends_S3AccessPoint{} +func (x *ManagedCASBackends_S3AccessPoint) Reset() { + *x = ManagedCASBackends_S3AccessPoint{} mi := &file_conf_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *BlobBackends_S3AccessPoint) String() string { +func (x *ManagedCASBackends_S3AccessPoint) String() string { return protoimpl.X.MessageStringOf(x) } -func (*BlobBackends_S3AccessPoint) ProtoMessage() {} +func (*ManagedCASBackends_S3AccessPoint) ProtoMessage() {} -func (x *BlobBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { +func (x *ManagedCASBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { mi := &file_conf_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -735,33 +735,33 @@ func (x *BlobBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use BlobBackends_S3AccessPoint.ProtoReflect.Descriptor instead. -func (*BlobBackends_S3AccessPoint) Descriptor() ([]byte, []int) { +// Deprecated: Use ManagedCASBackends_S3AccessPoint.ProtoReflect.Descriptor instead. +func (*ManagedCASBackends_S3AccessPoint) Descriptor() ([]byte, []int) { return file_conf_proto_rawDescGZIP(), []int{2, 0} } -func (x *BlobBackends_S3AccessPoint) GetBaseRoleArn() string { +func (x *ManagedCASBackends_S3AccessPoint) GetBaseRoleArn() string { if x != nil { return x.BaseRoleArn } return "" } -func (x *BlobBackends_S3AccessPoint) GetRegion() string { +func (x *ManagedCASBackends_S3AccessPoint) GetRegion() string { if x != nil { return x.Region } return "" } -func (x *BlobBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { +func (x *ManagedCASBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { if x != nil { return x.SessionDuration } return nil } -func (x *BlobBackends_S3AccessPoint) GetDevModeUseAmbientCredentials() bool { +func (x *ManagedCASBackends_S3AccessPoint) GetDevModeUseAmbientCredentials() bool { if x != nil { return x.DevModeUseAmbientCredentials } @@ -773,13 +773,13 @@ var File_conf_proto protoreflect.FileDescriptor const file_conf_proto_rawDesc = "" + "\n" + "\n" + - "conf.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xeb\x04\n" + + "conf.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xfe\x04\n" + "\tBootstrap\x12\x1f\n" + "\x06server\x18\x01 \x01(\v2\a.ServerR\x06server\x12\x19\n" + "\x04auth\x18\x02 \x01(\v2\x05.AuthR\x04auth\x12>\n" + "\robservability\x18\x03 \x01(\v2\x18.Bootstrap.ObservabilityR\robservability\x12L\n" + - "\x13credentials_service\x18\x04 \x01(\v2\x1b.credentials.v1.CredentialsR\x12credentialsService\x122\n" + - "\rblob_backends\x18\x05 \x01(\v2\r.BlobBackendsR\fblobBackends\x1a\xdf\x02\n" + + "\x13credentials_service\x18\x04 \x01(\v2\x1b.credentials.v1.CredentialsR\x12credentialsService\x12E\n" + + "\x14managed_cas_backends\x18\x05 \x01(\v2\x13.ManagedCASBackendsR\x12managedCasBackends\x1a\xdf\x02\n" + "\rObservability\x127\n" + "\x06sentry\x18\x01 \x01(\v2\x1f.Bootstrap.Observability.SentryR\x06sentry\x12:\n" + "\atracing\x18\x02 \x01(\v2 .Bootstrap.Observability.TracingR\atracing\x1a<\n" + @@ -812,9 +812,9 @@ const file_conf_proto_rawDesc = "" + "\x04addr\x18\x02 \x01(\tR\x04addr\x123\n" + "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12*\n" + "\n" + - "tls_config\x18\x04 \x01(\v2\v.Server.TLSR\ttlsConfig\"\xaf\x02\n" + - "\fBlobBackends\x12C\n" + - "\x0fs3_access_point\x18\x01 \x01(\v2\x1b.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\xd9\x01\n" + + "tls_config\x18\x04 \x01(\v2\v.Server.TLSR\ttlsConfig\"\xbb\x02\n" + + "\x12ManagedCASBackends\x12I\n" + + "\x0fs3_access_point\x18\x01 \x01(\v2!.ManagedCASBackends.S3AccessPointR\rs3AccessPoint\x1a\xd9\x01\n" + "\rS3AccessPoint\x12\"\n" + "\rbase_role_arn\x18\x01 \x01(\tR\vbaseRoleArn\x12\x16\n" + "\x06region\x18\x02 \x01(\tR\x06region\x12D\n" + @@ -838,38 +838,38 @@ func file_conf_proto_rawDescGZIP() []byte { var file_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_conf_proto_goTypes = []any{ - (*Bootstrap)(nil), // 0: Bootstrap - (*Server)(nil), // 1: Server - (*BlobBackends)(nil), // 2: BlobBackends - (*Auth)(nil), // 3: Auth - (*Bootstrap_Observability)(nil), // 4: Bootstrap.Observability - (*Bootstrap_Observability_Sentry)(nil), // 5: Bootstrap.Observability.Sentry - (*Bootstrap_Observability_Tracing)(nil), // 6: Bootstrap.Observability.Tracing - (*Server_CORS)(nil), // 7: Server.CORS - (*Server_HTTP)(nil), // 8: Server.HTTP - (*Server_TLS)(nil), // 9: Server.TLS - (*Server_GRPC)(nil), // 10: Server.GRPC - (*BlobBackends_S3AccessPoint)(nil), // 11: BlobBackends.S3AccessPoint - (*v1.Credentials)(nil), // 12: credentials.v1.Credentials - (*durationpb.Duration)(nil), // 13: google.protobuf.Duration + (*Bootstrap)(nil), // 0: Bootstrap + (*Server)(nil), // 1: Server + (*ManagedCASBackends)(nil), // 2: ManagedCASBackends + (*Auth)(nil), // 3: Auth + (*Bootstrap_Observability)(nil), // 4: Bootstrap.Observability + (*Bootstrap_Observability_Sentry)(nil), // 5: Bootstrap.Observability.Sentry + (*Bootstrap_Observability_Tracing)(nil), // 6: Bootstrap.Observability.Tracing + (*Server_CORS)(nil), // 7: Server.CORS + (*Server_HTTP)(nil), // 8: Server.HTTP + (*Server_TLS)(nil), // 9: Server.TLS + (*Server_GRPC)(nil), // 10: Server.GRPC + (*ManagedCASBackends_S3AccessPoint)(nil), // 11: ManagedCASBackends.S3AccessPoint + (*v1.Credentials)(nil), // 12: credentials.v1.Credentials + (*durationpb.Duration)(nil), // 13: google.protobuf.Duration } var file_conf_proto_depIdxs = []int32{ 1, // 0: Bootstrap.server:type_name -> Server 3, // 1: Bootstrap.auth:type_name -> Auth 4, // 2: Bootstrap.observability:type_name -> Bootstrap.Observability 12, // 3: Bootstrap.credentials_service:type_name -> credentials.v1.Credentials - 2, // 4: Bootstrap.blob_backends:type_name -> BlobBackends + 2, // 4: Bootstrap.managed_cas_backends:type_name -> ManagedCASBackends 8, // 5: Server.http:type_name -> Server.HTTP 10, // 6: Server.grpc:type_name -> Server.GRPC 8, // 7: Server.http_metrics:type_name -> Server.HTTP - 11, // 8: BlobBackends.s3_access_point:type_name -> BlobBackends.S3AccessPoint + 11, // 8: ManagedCASBackends.s3_access_point:type_name -> ManagedCASBackends.S3AccessPoint 5, // 9: Bootstrap.Observability.sentry:type_name -> Bootstrap.Observability.Sentry 6, // 10: Bootstrap.Observability.tracing:type_name -> Bootstrap.Observability.Tracing 13, // 11: Server.HTTP.timeout:type_name -> google.protobuf.Duration 7, // 12: Server.HTTP.cors:type_name -> Server.CORS 13, // 13: Server.GRPC.timeout:type_name -> google.protobuf.Duration 9, // 14: Server.GRPC.tls_config:type_name -> Server.TLS - 13, // 15: BlobBackends.S3AccessPoint.session_duration:type_name -> google.protobuf.Duration + 13, // 15: ManagedCASBackends.S3AccessPoint.session_duration:type_name -> google.protobuf.Duration 16, // [16:16] is the sub-list for method output_type 16, // [16:16] is the sub-list for method input_type 16, // [16:16] is the sub-list for extension type_name diff --git a/app/artifact-cas/internal/conf/conf.proto b/app/artifact-cas/internal/conf/conf.proto index b69c78591..a75aa71f4 100644 --- a/app/artifact-cas/internal/conf/conf.proto +++ b/app/artifact-cas/internal/conf/conf.proto @@ -25,10 +25,10 @@ message Bootstrap { Auth auth = 2; Observability observability = 3; credentials.v1.Credentials credentials_service = 4; - // Deployment-level configuration for storage backend providers that - // need ambient knobs beyond what's stored per-CASBackend. Optional — + // Deployment-level configuration for managed CAS storage backends + // (provisioned and operated by Chainloop, not by tenants). Optional — // omitting a sub-block keeps the corresponding provider unregistered. - BlobBackends blob_backends = 5; + ManagedCASBackends managed_cas_backends = 5; message Observability { Sentry sentry = 1; @@ -83,11 +83,11 @@ message Server { HTTP http_metrics = 3; } -// BlobBackends mirrors the controlplane's `BlobBackends` block. Defined -// independently here so the artifact-cas binary doesn't depend on the -// controlplane's protobuf package. Keep field numbering in sync across -// both definitions. -message BlobBackends { +// ManagedCASBackends mirrors the controlplane's `ManagedCASBackends` +// block. Defined independently here so the artifact-cas binary doesn't +// depend on the controlplane's protobuf package. Keep field numbering +// in sync across both definitions. +message ManagedCASBackends { S3AccessPoint s3_access_point = 1; message S3AccessPoint { diff --git a/app/controlplane/cmd/wire.go b/app/controlplane/cmd/wire.go index 27f64a28a..775785e48 100644 --- a/app/controlplane/cmd/wire.go +++ b/app/controlplane/cmd/wire.go @@ -61,7 +61,7 @@ func wireApp(context.Context, *conf.Bootstrap, credentials.ReaderWriter, log.Log wire.Bind(new(biz.CASClient), new(*biz.CASClientUseCase)), serviceOpts, wire.Value([]biz.CASClientOpts{}), - wire.FieldsOf(new(*conf.Bootstrap), "Server", "Auth", "Data", "CasServer", "ReferrerSharedIndex", "Onboarding", "PrometheusIntegration", "PolicyProviders", "NatsServer", "FederatedAuthentication", "OperationAuthorizationProvider", "BlobBackends"), + wire.FieldsOf(new(*conf.Bootstrap), "Server", "Auth", "Data", "CasServer", "ReferrerSharedIndex", "Onboarding", "PrometheusIntegration", "PolicyProviders", "NatsServer", "FederatedAuthentication", "OperationAuthorizationProvider", "ManagedCasBackends"), wire.FieldsOf(new(*conf.Data), "Database"), dispatcher.New, authz.NewCasbinEnforcer, @@ -129,11 +129,11 @@ func serviceOpts(l log.Logger, authzUC *biz.AuthzUseCase, pUC *biz.ProjectUseCas } // newLoaderOptions builds the loader.Options struct from the deployment -// Bootstrap. When `blob_backends.s3_access_point` is absent (the common -// case for on-prem) S3AccessPoint stays nil and the provider is not -// registered, leaving the binary's behaviour identical to the pre-managed -// CAS world. -func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { +// Bootstrap. When `managed_cas_backends.s3_access_point` is absent (the +// common case for on-prem) S3AccessPoint stays nil and the provider is +// not registered, leaving the binary's behaviour identical to the +// pre-managed-CAS world. +func newLoaderOptions(in *conf.ManagedCASBackends, l log.Logger) *loader.Options { opts := &loader.Options{Logger: l} if in == nil || in.GetS3AccessPoint() == nil { return opts diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 7c57eb661..4acf5afdf 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -65,8 +65,8 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr membershipRepo := data.NewMembershipRepo(dataData, groupRepo, logger) organizationRepo := data.NewOrganizationRepo(dataData, logger) casBackendRepo := data.NewCASBackendRepo(dataData, logger) - blobBackends := bootstrap.BlobBackends - options := newLoaderOptions(blobBackends, logger) + managedCASBackends := bootstrap.ManagedCasBackends + options := newLoaderOptions(managedCASBackends, logger) providers := loader.LoadProviders(readerWriter, options) bootstrap_CASServer := bootstrap.CasServer casServerDefaultOpts := newCASServerOptions(bootstrap_CASServer) @@ -469,11 +469,11 @@ func serviceOpts(l log.Logger, authzUC *biz.AuthzUseCase, pUC *biz.ProjectUseCas } // newLoaderOptions builds the loader.Options struct from the deployment -// Bootstrap. When `blob_backends.s3_access_point` is absent (the common -// case for on-prem) S3AccessPoint stays nil and the provider is not -// registered, leaving the binary's behaviour identical to the pre-managed -// CAS world. -func newLoaderOptions(in *conf.BlobBackends, l log.Logger) *loader.Options { +// Bootstrap. When `managed_cas_backends.s3_access_point` is absent (the +// common case for on-prem) S3AccessPoint stays nil and the provider is +// not registered, leaving the binary's behaviour identical to the +// pre-managed-CAS world. +func newLoaderOptions(in *conf.ManagedCASBackends, l log.Logger) *loader.Options { opts := &loader.Options{Logger: l} if in == nil || in.GetS3AccessPoint() == nil { return opts diff --git a/app/controlplane/configs/config.devel.yaml b/app/controlplane/configs/config.devel.yaml index 968a406b0..593a4f6b7 100644 --- a/app/controlplane/configs/config.devel.yaml +++ b/app/controlplane/configs/config.devel.yaml @@ -130,7 +130,7 @@ attestations: # deployments that don't use managed CAS — the pod's ambient AWS # identity (IRSA / instance profile / AWS_* env vars) is what calls # sts:AssumeRole on base_role_arn; no static credentials live here. -# blob_backends: +# managed_cas_backends: # s3_access_point: # base_role_arn: arn:aws:iam::123456789012:role/chainloop-cas-tenant # region: us-east-1 diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go index ab9920a22..4308b4b0e 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go @@ -84,12 +84,13 @@ type Bootstrap struct { OperationAuthorizationProvider *OperationAuthorizationProvider `protobuf:"bytes,20,opt,name=operation_authorization_provider,json=operationAuthorizationProvider,proto3" json:"operation_authorization_provider,omitempty"` // Attestation storage and processing options Attestations *Attestations `protobuf:"bytes,21,opt,name=attestations,proto3" json:"attestations,omitempty"` - // Deployment-level configuration for storage backend providers that - // need ambient knobs beyond what's stored per-CASBackend. Optional — - // omitting a sub-block keeps the corresponding provider unregistered. - BlobBackends *BlobBackends `protobuf:"bytes,22,opt,name=blob_backends,json=blobBackends,proto3" json:"blob_backends,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Deployment-level configuration for managed CAS storage backends + // (provisioned and operated by Chainloop, not by tenants). Optional — + // omitting a sub-block keeps the corresponding provider unregistered, + // so on-prem deployments without managed CAS are unaffected. + ManagedCasBackends *ManagedCASBackends `protobuf:"bytes,22,opt,name=managed_cas_backends,json=managedCasBackends,proto3" json:"managed_cas_backends,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Bootstrap) Reset() { @@ -270,42 +271,43 @@ func (x *Bootstrap) GetAttestations() *Attestations { return nil } -func (x *Bootstrap) GetBlobBackends() *BlobBackends { +func (x *Bootstrap) GetManagedCasBackends() *ManagedCASBackends { if x != nil { - return x.BlobBackends + return x.ManagedCasBackends } return nil } -// BlobBackends groups the additive, deployment-level config blocks for -// CAS storage backends. New providers append a nested message rather +// ManagedCASBackends groups the additive, deployment-level config +// blocks for the storage providers that back Chainloop-managed CAS +// backends. New managed providers append a nested message rather // than adding top-level fields to Bootstrap, so the surface stays // organised as more backends are added. -type BlobBackends struct { +type ManagedCASBackends struct { state protoimpl.MessageState `protogen:"open.v1"` // S3 Access Point provider — used by SaaS managed CAS to share one // physical bucket across tenants. Authentication uses the pod's // ambient AWS identity (IRSA / instance profile / env vars); no static // credentials live in this block by design. - S3AccessPoint *BlobBackends_S3AccessPoint `protobuf:"bytes,1,opt,name=s3_access_point,json=s3AccessPoint,proto3" json:"s3_access_point,omitempty"` + S3AccessPoint *ManagedCASBackends_S3AccessPoint `protobuf:"bytes,1,opt,name=s3_access_point,json=s3AccessPoint,proto3" json:"s3_access_point,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *BlobBackends) Reset() { - *x = BlobBackends{} +func (x *ManagedCASBackends) Reset() { + *x = ManagedCASBackends{} mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *BlobBackends) String() string { +func (x *ManagedCASBackends) String() string { return protoimpl.X.MessageStringOf(x) } -func (*BlobBackends) ProtoMessage() {} +func (*ManagedCASBackends) ProtoMessage() {} -func (x *BlobBackends) ProtoReflect() protoreflect.Message { +func (x *ManagedCASBackends) ProtoReflect() protoreflect.Message { mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -317,12 +319,12 @@ func (x *BlobBackends) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use BlobBackends.ProtoReflect.Descriptor instead. -func (*BlobBackends) Descriptor() ([]byte, []int) { +// Deprecated: Use ManagedCASBackends.ProtoReflect.Descriptor instead. +func (*ManagedCASBackends) Descriptor() ([]byte, []int) { return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1} } -func (x *BlobBackends) GetS3AccessPoint() *BlobBackends_S3AccessPoint { +func (x *ManagedCASBackends) GetS3AccessPoint() *ManagedCASBackends_S3AccessPoint { if x != nil { return x.S3AccessPoint } @@ -1343,7 +1345,7 @@ func (x *Bootstrap_Observability_Tracing) GetSamplingRatio() float64 { return 0 } -type BlobBackends_S3AccessPoint struct { +type ManagedCASBackends_S3AccessPoint struct { state protoimpl.MessageState `protogen:"open.v1"` // IAM role the controlplane / artifact-cas pod assumes per request // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every @@ -1366,20 +1368,20 @@ type BlobBackends_S3AccessPoint struct { sizeCache protoimpl.SizeCache } -func (x *BlobBackends_S3AccessPoint) Reset() { - *x = BlobBackends_S3AccessPoint{} +func (x *ManagedCASBackends_S3AccessPoint) Reset() { + *x = ManagedCASBackends_S3AccessPoint{} mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *BlobBackends_S3AccessPoint) String() string { +func (x *ManagedCASBackends_S3AccessPoint) String() string { return protoimpl.X.MessageStringOf(x) } -func (*BlobBackends_S3AccessPoint) ProtoMessage() {} +func (*ManagedCASBackends_S3AccessPoint) ProtoMessage() {} -func (x *BlobBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { +func (x *ManagedCASBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -1391,33 +1393,33 @@ func (x *BlobBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use BlobBackends_S3AccessPoint.ProtoReflect.Descriptor instead. -func (*BlobBackends_S3AccessPoint) Descriptor() ([]byte, []int) { +// Deprecated: Use ManagedCASBackends_S3AccessPoint.ProtoReflect.Descriptor instead. +func (*ManagedCASBackends_S3AccessPoint) Descriptor() ([]byte, []int) { return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1, 0} } -func (x *BlobBackends_S3AccessPoint) GetBaseRoleArn() string { +func (x *ManagedCASBackends_S3AccessPoint) GetBaseRoleArn() string { if x != nil { return x.BaseRoleArn } return "" } -func (x *BlobBackends_S3AccessPoint) GetRegion() string { +func (x *ManagedCASBackends_S3AccessPoint) GetRegion() string { if x != nil { return x.Region } return "" } -func (x *BlobBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { +func (x *ManagedCASBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { if x != nil { return x.SessionDuration } return nil } -func (x *BlobBackends_S3AccessPoint) GetDevModeUseAmbientCredentials() bool { +func (x *ManagedCASBackends_S3AccessPoint) GetDevModeUseAmbientCredentials() bool { if x != nil { return x.DevModeUseAmbientCredentials } @@ -1932,7 +1934,7 @@ var File_controlplane_config_v1_conf_proto protoreflect.FileDescriptor const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\n" + - "!controlplane/config/v1/conf.proto\x12\x16controlplane.config.v1\x1a\x1bbuf/validate/validate.proto\x1a#controlplane/config/v1/config.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xc0\x12\n" + + "!controlplane/config/v1/conf.proto\x12\x16controlplane.config.v1\x1a\x1bbuf/validate/validate.proto\x1a#controlplane/config/v1/config.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xd3\x12\n" + "\tBootstrap\x126\n" + "\x06server\x18\x01 \x01(\v2\x1e.controlplane.config.v1.ServerR\x06server\x120\n" + "\x04data\x18\x02 \x01(\v2\x1c.controlplane.config.v1.DataR\x04data\x120\n" + @@ -1960,8 +1962,8 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x15restrict_org_creation\x18\x12 \x01(\bR\x13restrictOrgCreation\x12(\n" + "\x10ui_dashboard_url\x18\x13 \x01(\tR\x0euiDashboardUrl\x12\x80\x01\n" + " operation_authorization_provider\x18\x14 \x01(\v26.controlplane.config.v1.OperationAuthorizationProviderR\x1eoperationAuthorizationProvider\x12H\n" + - "\fattestations\x18\x15 \x01(\v2$.controlplane.config.v1.AttestationsR\fattestations\x12I\n" + - "\rblob_backends\x18\x16 \x01(\v2$.controlplane.config.v1.BlobBackendsR\fblobBackends\x1a\x8d\x03\n" + + "\fattestations\x18\x15 \x01(\v2$.controlplane.config.v1.AttestationsR\fattestations\x12\\\n" + + "\x14managed_cas_backends\x18\x16 \x01(\v2*.controlplane.config.v1.ManagedCASBackendsR\x12managedCasBackends\x1a\x8d\x03\n" + "\rObservability\x12N\n" + "\x06sentry\x18\x01 \x01(\v26.controlplane.config.v1.Bootstrap.Observability.SentryR\x06sentry\x12Q\n" + "\atracing\x18\x02 \x01(\v27.controlplane.config.v1.Bootstrap.Observability.TracingR\atracing\x1a<\n" + @@ -1984,9 +1986,9 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x03uri\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x03uri\x12\x1f\n" + "\x05token\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x05token\x12\x1a\n" + "\breplicas\x18\x03 \x01(\x05R\breplicasB\x10\n" + - "\x0eauthentication\"\x9a\x04\n" + - "\fBlobBackends\x12Z\n" + - "\x0fs3_access_point\x18\x01 \x01(\v22.controlplane.config.v1.BlobBackends.S3AccessPointR\rs3AccessPoint\x1a\xad\x03\n" + + "\x0eauthentication\"\xa6\x04\n" + + "\x12ManagedCASBackends\x12`\n" + + "\x0fs3_access_point\x18\x01 \x01(\v28.controlplane.config.v1.ManagedCASBackends.S3AccessPointR\rs3AccessPoint\x1a\xad\x03\n" + "\rS3AccessPoint\x12\"\n" + "\rbase_role_arn\x18\x01 \x01(\tR\vbaseRoleArn\x12\x1f\n" + "\x06region\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06region\x12D\n" + @@ -2090,36 +2092,36 @@ func file_controlplane_config_v1_conf_proto_rawDescGZIP() []byte { var file_controlplane_config_v1_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_controlplane_config_v1_conf_proto_goTypes = []any{ - (*Bootstrap)(nil), // 0: controlplane.config.v1.Bootstrap - (*BlobBackends)(nil), // 1: controlplane.config.v1.BlobBackends - (*Attestations)(nil), // 2: controlplane.config.v1.Attestations - (*OperationAuthorizationProvider)(nil), // 3: controlplane.config.v1.OperationAuthorizationProvider - (*FederatedAuthentication)(nil), // 4: controlplane.config.v1.FederatedAuthentication - (*PolicyProvider)(nil), // 5: controlplane.config.v1.PolicyProvider - (*ReferrerSharedIndex)(nil), // 6: controlplane.config.v1.ReferrerSharedIndex - (*Server)(nil), // 7: controlplane.config.v1.Server - (*Data)(nil), // 8: controlplane.config.v1.Data - (*Auth)(nil), // 9: controlplane.config.v1.Auth - (*TSA)(nil), // 10: controlplane.config.v1.TSA - (*CA)(nil), // 11: controlplane.config.v1.CA - (*PrometheusIntegrationSpec)(nil), // 12: controlplane.config.v1.PrometheusIntegrationSpec - (*Bootstrap_Observability)(nil), // 13: controlplane.config.v1.Bootstrap.Observability - (*Bootstrap_CASServer)(nil), // 14: controlplane.config.v1.Bootstrap.CASServer - (*Bootstrap_NatsServer)(nil), // 15: controlplane.config.v1.Bootstrap.NatsServer - (*Bootstrap_Observability_Sentry)(nil), // 16: controlplane.config.v1.Bootstrap.Observability.Sentry - (*Bootstrap_Observability_Tracing)(nil), // 17: controlplane.config.v1.Bootstrap.Observability.Tracing - (*BlobBackends_S3AccessPoint)(nil), // 18: controlplane.config.v1.BlobBackends.S3AccessPoint - (*Server_HTTP)(nil), // 19: controlplane.config.v1.Server.HTTP - (*Server_TLS)(nil), // 20: controlplane.config.v1.Server.TLS - (*Server_GRPC)(nil), // 21: controlplane.config.v1.Server.GRPC - (*Data_Database)(nil), // 22: controlplane.config.v1.Data.Database - (*Auth_OIDC)(nil), // 23: controlplane.config.v1.Auth.OIDC - (*CA_FileCA)(nil), // 24: controlplane.config.v1.CA.FileCA - (*CA_EJBCA)(nil), // 25: controlplane.config.v1.CA.EJBCA - (*v1.Credentials)(nil), // 26: credentials.v1.Credentials - (*v11.OnboardingSpec)(nil), // 27: controlplane.config.v1.OnboardingSpec - (*v11.AllowList)(nil), // 28: controlplane.config.v1.AllowList - (*durationpb.Duration)(nil), // 29: google.protobuf.Duration + (*Bootstrap)(nil), // 0: controlplane.config.v1.Bootstrap + (*ManagedCASBackends)(nil), // 1: controlplane.config.v1.ManagedCASBackends + (*Attestations)(nil), // 2: controlplane.config.v1.Attestations + (*OperationAuthorizationProvider)(nil), // 3: controlplane.config.v1.OperationAuthorizationProvider + (*FederatedAuthentication)(nil), // 4: controlplane.config.v1.FederatedAuthentication + (*PolicyProvider)(nil), // 5: controlplane.config.v1.PolicyProvider + (*ReferrerSharedIndex)(nil), // 6: controlplane.config.v1.ReferrerSharedIndex + (*Server)(nil), // 7: controlplane.config.v1.Server + (*Data)(nil), // 8: controlplane.config.v1.Data + (*Auth)(nil), // 9: controlplane.config.v1.Auth + (*TSA)(nil), // 10: controlplane.config.v1.TSA + (*CA)(nil), // 11: controlplane.config.v1.CA + (*PrometheusIntegrationSpec)(nil), // 12: controlplane.config.v1.PrometheusIntegrationSpec + (*Bootstrap_Observability)(nil), // 13: controlplane.config.v1.Bootstrap.Observability + (*Bootstrap_CASServer)(nil), // 14: controlplane.config.v1.Bootstrap.CASServer + (*Bootstrap_NatsServer)(nil), // 15: controlplane.config.v1.Bootstrap.NatsServer + (*Bootstrap_Observability_Sentry)(nil), // 16: controlplane.config.v1.Bootstrap.Observability.Sentry + (*Bootstrap_Observability_Tracing)(nil), // 17: controlplane.config.v1.Bootstrap.Observability.Tracing + (*ManagedCASBackends_S3AccessPoint)(nil), // 18: controlplane.config.v1.ManagedCASBackends.S3AccessPoint + (*Server_HTTP)(nil), // 19: controlplane.config.v1.Server.HTTP + (*Server_TLS)(nil), // 20: controlplane.config.v1.Server.TLS + (*Server_GRPC)(nil), // 21: controlplane.config.v1.Server.GRPC + (*Data_Database)(nil), // 22: controlplane.config.v1.Data.Database + (*Auth_OIDC)(nil), // 23: controlplane.config.v1.Auth.OIDC + (*CA_FileCA)(nil), // 24: controlplane.config.v1.CA.FileCA + (*CA_EJBCA)(nil), // 25: controlplane.config.v1.CA.EJBCA + (*v1.Credentials)(nil), // 26: credentials.v1.Credentials + (*v11.OnboardingSpec)(nil), // 27: controlplane.config.v1.OnboardingSpec + (*v11.AllowList)(nil), // 28: controlplane.config.v1.AllowList + (*durationpb.Duration)(nil), // 29: google.protobuf.Duration } var file_controlplane_config_v1_conf_proto_depIdxs = []int32{ 7, // 0: controlplane.config.v1.Bootstrap.server:type_name -> controlplane.config.v1.Server @@ -2139,8 +2141,8 @@ var file_controlplane_config_v1_conf_proto_depIdxs = []int32{ 4, // 14: controlplane.config.v1.Bootstrap.federated_authentication:type_name -> controlplane.config.v1.FederatedAuthentication 3, // 15: controlplane.config.v1.Bootstrap.operation_authorization_provider:type_name -> controlplane.config.v1.OperationAuthorizationProvider 2, // 16: controlplane.config.v1.Bootstrap.attestations:type_name -> controlplane.config.v1.Attestations - 1, // 17: controlplane.config.v1.Bootstrap.blob_backends:type_name -> controlplane.config.v1.BlobBackends - 18, // 18: controlplane.config.v1.BlobBackends.s3_access_point:type_name -> controlplane.config.v1.BlobBackends.S3AccessPoint + 1, // 17: controlplane.config.v1.Bootstrap.managed_cas_backends:type_name -> controlplane.config.v1.ManagedCASBackends + 18, // 18: controlplane.config.v1.ManagedCASBackends.s3_access_point:type_name -> controlplane.config.v1.ManagedCASBackends.S3AccessPoint 19, // 19: controlplane.config.v1.Server.http:type_name -> controlplane.config.v1.Server.HTTP 21, // 20: controlplane.config.v1.Server.grpc:type_name -> controlplane.config.v1.Server.GRPC 19, // 21: controlplane.config.v1.Server.http_metrics:type_name -> controlplane.config.v1.Server.HTTP @@ -2152,7 +2154,7 @@ var file_controlplane_config_v1_conf_proto_depIdxs = []int32{ 16, // 27: controlplane.config.v1.Bootstrap.Observability.sentry:type_name -> controlplane.config.v1.Bootstrap.Observability.Sentry 17, // 28: controlplane.config.v1.Bootstrap.Observability.tracing:type_name -> controlplane.config.v1.Bootstrap.Observability.Tracing 21, // 29: controlplane.config.v1.Bootstrap.CASServer.grpc:type_name -> controlplane.config.v1.Server.GRPC - 29, // 30: controlplane.config.v1.BlobBackends.S3AccessPoint.session_duration:type_name -> google.protobuf.Duration + 29, // 30: controlplane.config.v1.ManagedCASBackends.S3AccessPoint.session_duration:type_name -> google.protobuf.Duration 29, // 31: controlplane.config.v1.Server.HTTP.timeout:type_name -> google.protobuf.Duration 29, // 32: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration 20, // 33: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto index 653255c2c..87a672943 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto @@ -127,17 +127,19 @@ message Bootstrap { // Attestation storage and processing options Attestations attestations = 21; - // Deployment-level configuration for storage backend providers that - // need ambient knobs beyond what's stored per-CASBackend. Optional — - // omitting a sub-block keeps the corresponding provider unregistered. - BlobBackends blob_backends = 22; + // Deployment-level configuration for managed CAS storage backends + // (provisioned and operated by Chainloop, not by tenants). Optional — + // omitting a sub-block keeps the corresponding provider unregistered, + // so on-prem deployments without managed CAS are unaffected. + ManagedCASBackends managed_cas_backends = 22; } -// BlobBackends groups the additive, deployment-level config blocks for -// CAS storage backends. New providers append a nested message rather +// ManagedCASBackends groups the additive, deployment-level config +// blocks for the storage providers that back Chainloop-managed CAS +// backends. New managed providers append a nested message rather // than adding top-level fields to Bootstrap, so the surface stays // organised as more backends are added. -message BlobBackends { +message ManagedCASBackends { // S3 Access Point provider — used by SaaS managed CAS to share one // physical bucket across tenants. Authentication uses the pod's // ambient AWS identity (IRSA / instance profile / env vars); no static From 5c08c9841ec466cb93dc504aa1003189b49b99e0 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 12:34:23 +0200 Subject: [PATCH 07/18] feat(casbackend): reject managed-only providers in service Create CASBackendService.Create previously accepted any provider ID present in the loader's provider map, including AWS-S3-ACCESS-POINT. A sufficiently determined user could craft a Create request that half-provisioned a managed row pointing at an AP ARN they don't own, bypassing the platform reconciler's trust boundary. Add an explicit isManagedOnlyProvider() guard at the front of Create so the public RPC fails fast with `managed CAS backends cannot be created via this API`. The platform reconciler still creates managed rows by calling biz.CASBackendUseCase.Create directly, which is unaffected. Update/SoftDelete are already guarded against managed rows in the biz layer. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- .../internal/service/casbackend.go | 20 ++++++++++++++ .../internal/service/casbackend_test.go | 26 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/app/controlplane/internal/service/casbackend.go b/app/controlplane/internal/service/casbackend.go index 1f2c62e24..071f9d332 100644 --- a/app/controlplane/internal/service/casbackend.go +++ b/app/controlplane/internal/service/casbackend.go @@ -21,6 +21,7 @@ import ( pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/go-kratos/kratos/v2/errors" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -66,6 +67,16 @@ func (s *CASBackendService) Create(ctx context.Context, req *pb.CASBackendServic return nil, err } + // Managed-only providers (currently AWS-S3-ACCESS-POINT) are + // reserved for the platform reconciler, which provisions them via + // the biz layer directly. Reject any attempt to create one through + // the public RPC so a user can't end up with a half-provisioned row + // pointing at an AP they don't own. + if isManagedOnlyProvider(req.Provider) { + return nil, errors.BadRequest("invalid CAS backend", + "managed CAS backends cannot be created via this API") + } + backendP, ok := s.providers[req.Provider] if !ok { return nil, errors.BadRequest("invalid CAS backend", "invalid CAS backend") @@ -237,3 +248,12 @@ func bizCASBackendToPb(in *biz.CASBackend) *pb.CASBackendItem { return r } + +// isManagedOnlyProvider returns true when the supplied provider ID can +// only be instantiated by the platform reconciler — never by a user via +// the public CASBackendService.Create RPC. Today that's just the S3 +// Access Point provider; if more managed providers land later, list +// them here. +func isManagedOnlyProvider(id string) bool { + return id == s3accesspoint.ProviderID +} diff --git a/app/controlplane/internal/service/casbackend_test.go b/app/controlplane/internal/service/casbackend_test.go index 2e2992210..11d5c7842 100644 --- a/app/controlplane/internal/service/casbackend_test.go +++ b/app/controlplane/internal/service/casbackend_test.go @@ -20,6 +20,7 @@ import ( "time" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/google/uuid" "github.com/stretchr/testify/assert" ) @@ -67,3 +68,28 @@ func TestBizCASBackendToPb_HidesManagedDetails(t *testing.T) { assert.True(t, got.IsManaged) }) } + +// TestIsManagedOnlyProvider locks down which provider IDs are reserved +// for the platform reconciler. If a new managed provider is added but +// this list isn't updated, users would be able to create the row +// directly via CASBackendService.Create — a privilege escalation against +// the managed-CAS trust model. +func TestIsManagedOnlyProvider(t *testing.T) { + tests := []struct { + id string + expected bool + }{ + {s3accesspoint.ProviderID, true}, + {"AWS-S3", false}, + {"OCI", false}, + {"AzureBlob", false}, + {"INLINE", false}, + {"", false}, + {"unknown-provider", false}, + } + for _, tc := range tests { + t.Run(tc.id, func(t *testing.T) { + assert.Equal(t, tc.expected, isManagedOnlyProvider(tc.id)) + }) + } +} From 094724911d924aad11d45ba53eee192206f2dfdc Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 12:50:58 +0200 Subject: [PATCH 08/18] update comment Signed-off-by: Jose I. Paris --- app/controlplane/internal/service/casbackend.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/controlplane/internal/service/casbackend.go b/app/controlplane/internal/service/casbackend.go index 071f9d332..958ea0deb 100644 --- a/app/controlplane/internal/service/casbackend.go +++ b/app/controlplane/internal/service/casbackend.go @@ -67,11 +67,7 @@ func (s *CASBackendService) Create(ctx context.Context, req *pb.CASBackendServic return nil, err } - // Managed-only providers (currently AWS-S3-ACCESS-POINT) are - // reserved for the platform reconciler, which provisions them via - // the biz layer directly. Reject any attempt to create one through - // the public RPC so a user can't end up with a half-provisioned row - // pointing at an AP they don't own. + // Managed-only providers (currently AWS-S3-ACCESS-POINT) are reserved if isManagedOnlyProvider(req.Provider) { return nil, errors.BadRequest("invalid CAS backend", "managed CAS backends cannot be created via this API") @@ -249,11 +245,7 @@ func bizCASBackendToPb(in *biz.CASBackend) *pb.CASBackendItem { return r } -// isManagedOnlyProvider returns true when the supplied provider ID can -// only be instantiated by the platform reconciler — never by a user via -// the public CASBackendService.Create RPC. Today that's just the S3 -// Access Point provider; if more managed providers land later, list -// them here. +// isManagedOnlyProvider returns true when the supplied provider is managed func isManagedOnlyProvider(id string) bool { return id == s3accesspoint.ProviderID } From 8e2be4ddc8a3701f3b22c16620e91b5e749a3604 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 16:25:33 +0200 Subject: [PATCH 09/18] refactor(casbackend): tighten managed-CAS org plumbing per PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the PR review on #3121: * JWT OrgID claim is restored to backend.OrganizationID (instead of the authenticated caller's currentOrg). For cross-org downloads (FindCASMappingForDownloadByUser may return a backend from any org the caller belongs to) the JWT must address the AP that actually owns the data; authorization is enforced earlier by the mapping lookup. Inline comments at those call sites were dropped — the reasoning lives in this commit and the design doc. * CASCredsOpts.OrgID is now uuid.UUID instead of string, matching every other org-id field in biz; the JWT boundary stringifies once and treats uuid.Nil as "no managed binding". * The s3accesspoint-specific ctx-key helper moves up to the pkg/blobmanager umbrella as backend.WithRequestingOrg / backend.RequestingOrgFromContext. Generic primitive, not tied to any one provider, and reusable for future managed backends. * Setting the requesting-org on ctx is now done by an auth-boundary middleware in app/artifact-cas/internal/server/auth.go (requestingOrgMiddleware for unary gRPC, jwtAuthFunc enrichment for stream gRPC, requestingOrgHTTPMiddleware for the download HTTP handler). The service layer no longer carries loadBackendForClaims; all four CAS service entry points are back to plain loadBackend. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- app/artifact-cas/internal/server/auth.go | 69 +++++++++++++++++++ app/artifact-cas/internal/server/grpc.go | 9 ++- app/artifact-cas/internal/server/http.go | 6 +- .../internal/service/bytestream.go | 4 +- app/artifact-cas/internal/service/download.go | 2 +- app/artifact-cas/internal/service/resource.go | 2 +- app/artifact-cas/internal/service/service.go | 32 ++------- .../internal/service/attestation.go | 8 +-- .../internal/service/cascredential.go | 7 +- .../internal/service/casredirect.go | 7 +- app/controlplane/pkg/biz/cascredentials.go | 14 ++-- pkg/blobmanager/backend.go | 32 +++++++++ pkg/blobmanager/backend_test.go | 44 ++++++++++++ pkg/blobmanager/s3accesspoint/backend.go | 6 +- pkg/blobmanager/s3accesspoint/backend_test.go | 11 +-- pkg/blobmanager/s3accesspoint/provider.go | 25 ------- .../s3accesspoint/provider_test.go | 15 ---- 17 files changed, 188 insertions(+), 105 deletions(-) create mode 100644 app/artifact-cas/internal/server/auth.go create mode 100644 pkg/blobmanager/backend_test.go diff --git a/app/artifact-cas/internal/server/auth.go b/app/artifact-cas/internal/server/auth.go new file mode 100644 index 000000000..c5b0ad994 --- /dev/null +++ b/app/artifact-cas/internal/server/auth.go @@ -0,0 +1,69 @@ +// +// 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 server + +import ( + "context" + nhttp "net/http" + + "github.com/go-kratos/kratos/v2/middleware" + jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" + + casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" + backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" +) + +// withRequestingOrgFromClaims reads the CAS JWT claims (already +// verified and stashed in ctx by the JWT middleware) and stamps the +// org UUID on the context via backend.WithRequestingOrg. Managed CAS +// providers consume this to scope per-tenant STS sessions; other +// providers ignore it. +// +// A missing or empty OrgID is treated as "no managed binding" — ctx +// passes through unchanged so legacy tokens minted before the org-id +// claim was added continue to work for non-managed providers. +func withRequestingOrgFromClaims(ctx context.Context) context.Context { + raw, ok := jwtMiddleware.FromContext(ctx) + if !ok { + return ctx + } + claims, ok := raw.(*casJWT.Claims) + if !ok || claims.OrgID == "" { + return ctx + } + return backend.WithRequestingOrg(ctx, claims.OrgID) +} + +// requestingOrgMiddleware is a kratos middleware that runs after the +// JWT middleware on unary gRPC requests and enriches ctx with the +// requesting org from the verified claims. +func requestingOrgMiddleware() middleware.Middleware { + return func(handler middleware.Handler) middleware.Handler { + return func(ctx context.Context, req any) (any, error) { + return handler(withRequestingOrgFromClaims(ctx), req) + } + } +} + +// requestingOrgHTTPMiddleware wraps an HTTP handler so the request +// context carries the requesting org. Apply it BETWEEN the JWT +// middleware (which populates the claims in ctx) and the actual +// handler. +func requestingOrgHTTPMiddleware(next nhttp.Handler) nhttp.Handler { + return nhttp.HandlerFunc(func(w nhttp.ResponseWriter, r *nhttp.Request) { + next.ServeHTTP(w, r.WithContext(withRequestingOrgFromClaims(r.Context()))) + }) +} diff --git a/app/artifact-cas/internal/server/grpc.go b/app/artifact-cas/internal/server/grpc.go index 15a76be41..fc5ea2f7d 100644 --- a/app/artifact-cas/internal/server/grpc.go +++ b/app/artifact-cas/internal/server/grpc.go @@ -86,6 +86,11 @@ func NewGRPCServer(c *conf.Server, authConf *conf.Auth, byteService *service.Byt loadPublicKey(rawKey), jwtMiddleware.WithSigningMethod(casJWT.SigningMethod), jwtMiddleware.WithClaims(func() jwt.Claims { return &casJWT.Claims{} })), + // Runs after the JWT middleware on authenticated unary + // RPCs; stamps the requesting-org UUID from the verified + // claims onto ctx so managed CAS providers can scope STS + // sessions per tenant. + requestingOrgMiddleware(), ).Match(requireAuthentication()).Build(), ), @@ -183,7 +188,9 @@ func jwtAuthFunc(keyFunc jwt.Keyfunc, signingMethod jwt.SigningMethod) grpc_auth return nil, err } - return jwtMiddleware.NewContext(ctx, claims), nil + ctx = jwtMiddleware.NewContext(ctx, claims) + // Same enrichment the unary chain does via requestingOrgMiddleware. + return withRequestingOrgFromClaims(ctx), nil } } diff --git a/app/artifact-cas/internal/server/http.go b/app/artifact-cas/internal/server/http.go index 3a0189a66..528f09dd9 100644 --- a/app/artifact-cas/internal/server/http.go +++ b/app/artifact-cas/internal/server/http.go @@ -65,7 +65,11 @@ func NewHTTPServer(c *conf.Server, authConf *conf.Auth, downloadSvc *service.Dow srv := http.NewServer(opts...) - downloadHandler := middlewares_http.AuthFromQueryParam(loadPublicKey(rawKey), claimsFunc(), casJWT.SigningMethod, downloadSvc) + // AuthFromQueryParam verifies the JWT and stashes the claims in + // ctx; requestingOrgHTTPMiddleware then promotes the org-id claim + // into a value consumable by managed CAS providers (mirrors the + // gRPC chains). + downloadHandler := middlewares_http.AuthFromQueryParam(loadPublicKey(rawKey), claimsFunc(), casJWT.SigningMethod, requestingOrgHTTPMiddleware(downloadSvc)) srv.Handle(service.DownloadPath, CORSMiddleware(c.GetHttp().GetCors().GetAllowOrigins(), downloadHandler)) api.RegisterStatusServiceHTTPServer(srv, service.NewStatusService(Version, providers)) return srv, nil diff --git a/app/artifact-cas/internal/service/bytestream.go b/app/artifact-cas/internal/service/bytestream.go index 8af4611d2..ba462bfd0 100644 --- a/app/artifact-cas/internal/service/bytestream.go +++ b/app/artifact-cas/internal/service/bytestream.go @@ -80,7 +80,7 @@ func (s *ByteStreamService) Write(stream bytestream.ByteStream_WriteServer) erro return kerrors.BadRequest("resource name", err.Error()) } - ctx, storageBackend, err := s.loadBackendForClaims(ctx, info) + storageBackend, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID) if err != nil && kerrors.IsNotFound(err) { return err } else if err != nil { @@ -161,7 +161,7 @@ func (s *ByteStreamService) Read(req *bytestream.ReadRequest, stream bytestream. return kerrors.BadRequest("resource name", "empty resource name") } - ctx, backend, err := s.loadBackendForClaims(ctx, info) + backend, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID) if err != nil && kerrors.IsNotFound(err) { return err } else if err != nil { diff --git a/app/artifact-cas/internal/service/download.go b/app/artifact-cas/internal/service/download.go index 32b8ee01f..6100f8082 100644 --- a/app/artifact-cas/internal/service/download.go +++ b/app/artifact-cas/internal/service/download.go @@ -76,7 +76,7 @@ func (s *DownloadService) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - ctx, b, err := s.loadBackendForClaims(ctx, auth) + b, err := s.loadBackend(ctx, auth.BackendType, auth.StoredSecretID) if err != nil && kerrors.IsNotFound(err) { http.Error(w, "backend not found", http.StatusNotFound) return diff --git a/app/artifact-cas/internal/service/resource.go b/app/artifact-cas/internal/service/resource.go index 15fa50df7..2c6924bd4 100644 --- a/app/artifact-cas/internal/service/resource.go +++ b/app/artifact-cas/internal/service/resource.go @@ -48,7 +48,7 @@ func (s *ResourceService) Describe(ctx context.Context, req *v1.ResourceServiceD return nil, err } - ctx, b, err := s.loadBackendForClaims(ctx, info) + b, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID) if err != nil && errors.IsNotFound(err) { return nil, err } else if err != nil { diff --git a/app/artifact-cas/internal/service/service.go b/app/artifact-cas/internal/service/service.go index c62f6b4dc..93f4e5ebd 100644 --- a/app/artifact-cas/internal/service/service.go +++ b/app/artifact-cas/internal/service/service.go @@ -23,7 +23,6 @@ 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/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/servicelogger" kerrors "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/log" @@ -50,11 +49,10 @@ func (s *commonService) loadBackend(ctx context.Context, providerType, secretID s.log.Infow("msg", "selected provider", "provider", providerType) - // Retrieve the backend from where to download the file. The context - // passed here is what the backend's STS-backed credentials provider - // will see; the caller is responsible for having enriched it with - // s3accesspoint.WithRequestingOrg when the request came in via an - // authenticated CAS JWT. See loadBackendForClaims. + // The requesting-org enrichment for managed CAS providers is done + // at the auth boundary (see server.requestingOrgMiddleware / + // requestingOrgHTTPMiddleware), so ctx already carries everything + // the backend needs. backend, err := p.FromCredentials(ctx, secretID) if err != nil { return nil, fmt.Errorf("failed to retrieve backend: %w", err) @@ -63,28 +61,6 @@ func (s *commonService) loadBackend(ctx context.Context, providerType, secretID return backend, nil } -// loadBackendForClaims is the request-scoped wrapper around loadBackend. -// It pulls the requesting-org UUID out of the CAS JWT claims and stamps -// it onto the context so providers that need per-tenant attribution -// (currently AWS-S3-ACCESS-POINT) can mint correctly-scoped sessions. -// -// The OrgID claim is empty for legacy tokens minted before this PR; in -// that case WithRequestingOrg sets an empty value and only managed -// providers (which fail closed) will reject the request — every existing -// provider ignores the key entirely, so non-managed flows stay unchanged. -// -// Returns the loaded backend together with the enriched context so the -// caller can reuse it for the subsequent Upload/Download calls. Callers -// MUST use the returned context, not the original one. -func (s *commonService) loadBackendForClaims(ctx context.Context, claims *casJWT.Claims) (context.Context, backend.UploaderDownloader, error) { - ctx = s3accesspoint.WithRequestingOrg(ctx, claims.OrgID) - b, err := s.loadBackend(ctx, claims.BackendType, claims.StoredSecretID) - if err != nil { - return ctx, nil, err - } - return ctx, b, nil -} - type NewOpt func(s *commonService) func WithLogger(logger log.Logger) NewOpt { diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index 8970a602e..4091b2792 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -494,13 +494,7 @@ func (s *AttestationService) GetUploadCreds(ctx context.Context, req *cpAPI.Atte // Return the backend information and associated credentials (if applicable) resp := &cpAPI.AttestationServiceGetUploadCredsResponse_Result{Backend: bizCASBackendToPb(backend)} if backend.SecretName != "" { - // OrgID comes from the authenticated robot account (the caller), - // not the backend's owner. Even though GetByIDInOrgOrPublic above - // already scopes the lookup to robotAccount.OrgID, the security - // invariant for managed CAS requires the JWT org-id claim to - // originate from the caller's identity rather than the row we - // happened to resolve. - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes, OrgID: robotAccount.OrgID} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID} t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/cascredential.go b/app/controlplane/internal/service/cascredential.go index 008727976..cfec6038b 100644 --- a/app/controlplane/internal/service/cascredential.go +++ b/app/controlplane/internal/service/cascredential.go @@ -149,12 +149,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") } - // OrgID MUST come from the authenticated caller, not the resolved backend. - // For managed CAS the JWT's org-id claim drives the AssumeRole session - // name and AP-policy aws:userid match; pulling it from backend.OrganizationID - // would short-circuit that check against the very row a tampered secret - // could redirect. - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: currentOrg.ID} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID} t, err := s.casUC.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/casredirect.go b/app/controlplane/internal/service/casredirect.go index 78a250a8c..9f0dc136c 100644 --- a/app/controlplane/internal/service/casredirect.go +++ b/app/controlplane/internal/service/casredirect.go @@ -126,12 +126,7 @@ func (s *CASRedirectService) GetDownloadURL(ctx context.Context, req *pb.GetDown // 2- add authentication token to the query params ?t=[token] if backend.SecretName != "" { - // OrgID comes from the authenticated caller (currentOrg from - // ctx), not the resolved backend. For managed CAS this is what - // keys the AssumeRole session name and the AP-policy aws:userid - // match; deriving it from backend.OrganizationID would weaken - // the cross-tenant guarantee. - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes, OrgID: currentOrg.ID} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID} t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/pkg/biz/cascredentials.go b/app/controlplane/pkg/biz/cascredentials.go index 73374a53d..5b3e08680 100644 --- a/app/controlplane/pkg/biz/cascredentials.go +++ b/app/controlplane/pkg/biz/cascredentials.go @@ -21,6 +21,7 @@ import ( conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt" robotaccount "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" + "github.com/google/uuid" ) type CASCredentialsUseCase struct { @@ -48,12 +49,17 @@ type CASCredsOpts struct { SecretPath string // path to for example the OCI secret in the vault Role robotaccount.Role MaxBytes int64 - // OrgID is the requesting organization's UUID. Required for managed + // OrgID is the org the CAS backend belongs to. Required for managed // backends (e.g. AWS-S3-ACCESS-POINT) that need to scope per-tenant - // STS sessions; optional for the others (OCI, S3, AzureBlob). - OrgID string + // STS sessions; uuid.Nil is treated as "absent" for the others + // (OCI, S3, AzureBlob). + OrgID uuid.UUID } func (uc *CASCredentialsUseCase) GenerateTemporaryCredentials(backendRef *CASCredsOpts) (string, error) { - return uc.jwtBuilder.GenerateJWT(backendRef.BackendType, backendRef.SecretPath, jwt.CASAudience, backendRef.Role, backendRef.MaxBytes, backendRef.OrgID) + var orgID string + if backendRef.OrgID != uuid.Nil { + orgID = backendRef.OrgID.String() + } + return uc.jwtBuilder.GenerateJWT(backendRef.BackendType, backendRef.SecretPath, jwt.CASAudience, backendRef.Role, backendRef.MaxBytes, orgID) } diff --git a/pkg/blobmanager/backend.go b/pkg/blobmanager/backend.go index 37652c009..6d295932f 100644 --- a/pkg/blobmanager/backend.go +++ b/pkg/blobmanager/backend.go @@ -70,3 +70,35 @@ type Providers map[string]Provider func DetectedMediaType(b []byte) types.MediaType { return types.MediaType(strings.Split(http.DetectContentType(b), ";")[0]) } + +// requestingOrgCtxKey is unexported so callers must go through +// WithRequestingOrg / RequestingOrgFromContext; no risk of accidental +// collision with another package's keys. +type requestingOrgCtxKey struct{} + +// WithRequestingOrg returns a derived context that carries the +// authenticated requesting organization's UUID. Managed backends +// (currently AWS-S3-ACCESS-POINT) consume this value to scope per- +// tenant STS sessions; non-managed backends ignore it. +// +// The value MUST come from the verified caller identity (e.g. a CAS +// JWT claim), NOT from a resolved CASBackend row or its secret blob. +// The whole secret-tampering defense for managed CAS depends on this +// being a source the attacker can't rewrite together with the secret +// store. +// +// Callers typically set this once at the auth boundary (the CAS +// server's JWT middleware) and let the value flow through ctx into +// the backend's request handlers. +func WithRequestingOrg(ctx context.Context, orgUUID string) context.Context { + return context.WithValue(ctx, requestingOrgCtxKey{}, orgUUID) +} + +// RequestingOrgFromContext extracts the requesting org UUID previously +// stamped by WithRequestingOrg. Empty string means "no caller set the +// key" — backends that need a tenant identifier (e.g. managed CAS) +// should treat that as a fail-closed condition. +func RequestingOrgFromContext(ctx context.Context) string { + v, _ := ctx.Value(requestingOrgCtxKey{}).(string) + return v +} diff --git a/pkg/blobmanager/backend_test.go b/pkg/blobmanager/backend_test.go new file mode 100644 index 000000000..17cab7414 --- /dev/null +++ b/pkg/blobmanager/backend_test.go @@ -0,0 +1,44 @@ +// +// 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 backend_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" +) + +// TestWithRequestingOrg_RoundTrip pins the ctx-key contract relied on +// by managed CAS providers (e.g. s3accesspoint), which read the org +// UUID via backend.RequestingOrgFromContext to mint per-tenant STS +// sessions. Changing the key type or accessor without updating those +// providers would silently break the fail-closed path. +func TestWithRequestingOrg_RoundTrip(t *testing.T) { + // Empty by default. + assert.Empty(t, backend.RequestingOrgFromContext(context.Background())) + + ctx := backend.WithRequestingOrg(context.Background(), "org-abc") + assert.Equal(t, "org-abc", backend.RequestingOrgFromContext(ctx)) + + // Overwrite uses the most recent value — important so a middleware + // that sets the org isn't silently overridden by a stale value + // further down the stack. + ctx = backend.WithRequestingOrg(ctx, "org-xyz") + assert.Equal(t, "org-xyz", backend.RequestingOrgFromContext(ctx)) +} diff --git a/pkg/blobmanager/s3accesspoint/backend.go b/pkg/blobmanager/s3accesspoint/backend.go index 8ac279b1a..12b23d1b0 100644 --- a/pkg/blobmanager/s3accesspoint/backend.go +++ b/pkg/blobmanager/s3accesspoint/backend.go @@ -130,7 +130,7 @@ func NewBackend(ctx context.Context, cfg *Config, creds *Credentials) (*Backend, // invariant the credentials provider enforces, just surfaced earlier // with a clearer error. func (b *Backend) keyFor(ctx context.Context, digest string) (string, error) { - orgUUID := requestingOrgFromContext(ctx) + orgUUID := backend.RequestingOrgFromContext(ctx) if orgUUID == "" { return "", ErrMissingRequestingOrg } @@ -236,7 +236,7 @@ func (b *Backend) Download(ctx context.Context, w io.Writer, digest string) erro // s3 backend's variant this MUST be invoked with a context carrying // WithRequestingOrg; otherwise it fails closed. func (b *Backend) CheckWritePermissions(ctx context.Context) error { - orgUUID := requestingOrgFromContext(ctx) + orgUUID := backend.RequestingOrgFromContext(ctx) if orgUUID == "" { return ErrMissingRequestingOrg } @@ -288,7 +288,7 @@ type sessionCredentialsProvider struct { // be cheap to call (the cache wrapper deduplicates concurrent misses and // caches valid creds until ExpiresIn). func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { - orgUUID := requestingOrgFromContext(ctx) + orgUUID := backend.RequestingOrgFromContext(ctx) if orgUUID == "" { return aws.Credentials{}, ErrMissingRequestingOrg } diff --git a/pkg/blobmanager/s3accesspoint/backend_test.go b/pkg/blobmanager/s3accesspoint/backend_test.go index c9baefa41..ea476a020 100644 --- a/pkg/blobmanager/s3accesspoint/backend_test.go +++ b/pkg/blobmanager/s3accesspoint/backend_test.go @@ -22,6 +22,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" pb "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" + backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -29,7 +30,7 @@ import ( // TestBackend_FailClosedWithoutRequestingOrg is the load-bearing fail- // closed test: any backend operation that would normally hit AWS must // refuse to even attempt the call when the caller forgot to enrich the -// context with WithRequestingOrg. This test does NOT need LocalStack — +// context with backend.WithRequestingOrg. This test does NOT need LocalStack — // the credential provider rejects the request before any AWS SDK code // runs. // @@ -37,7 +38,7 @@ import ( // credential chain. func TestBackend_FailClosedWithoutRequestingOrg(t *testing.T) { b := newTestBackend(t) - ctx := context.Background() // intentionally no WithRequestingOrg + ctx := context.Background() // intentionally no backend.WithRequestingOrg t.Run("upload", func(t *testing.T) { err := b.Upload(ctx, bytes.NewReader([]byte("data")), @@ -81,9 +82,9 @@ func TestBackend_KeyDerivedFromRequestingOrg(t *testing.T) { }} digest := "deadbeef" - keyA, err := b.keyFor(WithRequestingOrg(context.Background(), "org-A"), digest) + keyA, err := b.keyFor(backend.WithRequestingOrg(context.Background(), "org-A"), digest) require.NoError(t, err) - keyB, err := b.keyFor(WithRequestingOrg(context.Background(), "org-B"), digest) + keyB, err := b.keyFor(backend.WithRequestingOrg(context.Background(), "org-B"), digest) require.NoError(t, err) assert.Equal(t, "org-A/sha256:deadbeef", keyA) @@ -143,7 +144,7 @@ func TestSessionCredentialsProvider_DevModeShortCircuit(t *testing.T) { } t.Run("returns ambient credentials when org is set", func(t *testing.T) { - ctx := WithRequestingOrg(context.Background(), "org-A") + ctx := backend.WithRequestingOrg(context.Background(), "org-A") got, err := p.Retrieve(ctx) require.NoError(t, err) assert.Equal(t, "AKDEV", got.AccessKeyID) diff --git a/pkg/blobmanager/s3accesspoint/provider.go b/pkg/blobmanager/s3accesspoint/provider.go index 1c99b1a63..4c4f31ae1 100644 --- a/pkg/blobmanager/s3accesspoint/provider.go +++ b/pkg/blobmanager/s3accesspoint/provider.go @@ -243,28 +243,3 @@ func (p *BackendProvider) ValidateAndExtractCredentials(location string, credsJS } return &creds, nil } - -// requestingOrgCtxKey is unexported so callers must go through -// WithRequestingOrg / requestingOrgFromContext; no risk of accidental -// collision with another package's keys. -type requestingOrgCtxKey struct{} - -// WithRequestingOrg returns a derived context that carries the -// authenticated requesting organization's UUID. Every biz/service path -// that hands a managed-AP backend off to Upload/Download MUST enrich the -// ctx via this helper; without it the backend fails closed. -// -// The value MUST come from the request's authenticated identity (e.g. -// usercontext.CurrentOrg(ctx).ID), NOT from the resolved CASBackend or its -// secret blob. The whole secret-tampering defense depends on this being a -// source the attacker can't rewrite together with the secret store. -func WithRequestingOrg(ctx context.Context, orgUUID string) context.Context { - return context.WithValue(ctx, requestingOrgCtxKey{}, orgUUID) -} - -// requestingOrgFromContext extracts the requesting org UUID. Empty string -// means "no caller set the key" — the backend treats this as a hard error. -func requestingOrgFromContext(ctx context.Context) string { - v, _ := ctx.Value(requestingOrgCtxKey{}).(string) - return v -} diff --git a/pkg/blobmanager/s3accesspoint/provider_test.go b/pkg/blobmanager/s3accesspoint/provider_test.go index d24ebb4e4..89741cdc3 100644 --- a/pkg/blobmanager/s3accesspoint/provider_test.go +++ b/pkg/blobmanager/s3accesspoint/provider_test.go @@ -152,21 +152,6 @@ func TestNewBackendProvider_NormalizesSessionDuration(t *testing.T) { assert.Equal(t, custom, p2.cfg.SessionDuration) } -func TestWithRequestingOrg_RoundTrip(t *testing.T) { - // Empty by default. - assert.Empty(t, requestingOrgFromContext(context.Background())) - - ctx := WithRequestingOrg(context.Background(), "org-abc") - assert.Equal(t, "org-abc", requestingOrgFromContext(ctx)) - - // Overwrite is allowed and uses the most recent value (mirrors - // context.WithValue semantics — important so a middleware that sets - // the org doesn't get silently overridden by a stale value further - // down the stack). - ctx = WithRequestingOrg(ctx, "org-xyz") - assert.Equal(t, "org-xyz", requestingOrgFromContext(ctx)) -} - func TestNewBackendProvider_FailsOnBadConfig(t *testing.T) { _, err := NewBackendProvider(&Config{Region: "us-east-1"}, stubReader{}) assert.ErrorContains(t, err, "base_role_arn") From 3e04cef2dbaa93d3e8dc90d644a0e6407913c1ce Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 17:30:04 +0200 Subject: [PATCH 10/18] refactor Signed-off-by: Jose I. Paris --- app/artifact-cas/internal/server/auth.go | 69 ----------------- app/artifact-cas/internal/server/grpc.go | 9 +-- app/artifact-cas/internal/server/http.go | 6 +- .../internal/service/bytestream.go | 4 +- app/artifact-cas/internal/service/download.go | 2 +- app/artifact-cas/internal/service/resource.go | 3 +- app/artifact-cas/internal/service/service.go | 34 +-------- .../internal/service/service_test.go | 75 ------------------- internal/robotaccount/cas/robotaccount.go | 35 ++++++++- .../robotaccount/cas/robotaccount_test.go | 74 ++++++++++++++++++ pkg/blobmanager/backend.go | 32 -------- pkg/blobmanager/backend_test.go | 44 ----------- pkg/blobmanager/s3accesspoint/backend.go | 32 +++++--- 13 files changed, 134 insertions(+), 285 deletions(-) delete mode 100644 app/artifact-cas/internal/server/auth.go delete mode 100644 pkg/blobmanager/backend_test.go diff --git a/app/artifact-cas/internal/server/auth.go b/app/artifact-cas/internal/server/auth.go deleted file mode 100644 index c5b0ad994..000000000 --- a/app/artifact-cas/internal/server/auth.go +++ /dev/null @@ -1,69 +0,0 @@ -// -// 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 server - -import ( - "context" - nhttp "net/http" - - "github.com/go-kratos/kratos/v2/middleware" - jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" - - casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" - backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" -) - -// withRequestingOrgFromClaims reads the CAS JWT claims (already -// verified and stashed in ctx by the JWT middleware) and stamps the -// org UUID on the context via backend.WithRequestingOrg. Managed CAS -// providers consume this to scope per-tenant STS sessions; other -// providers ignore it. -// -// A missing or empty OrgID is treated as "no managed binding" — ctx -// passes through unchanged so legacy tokens minted before the org-id -// claim was added continue to work for non-managed providers. -func withRequestingOrgFromClaims(ctx context.Context) context.Context { - raw, ok := jwtMiddleware.FromContext(ctx) - if !ok { - return ctx - } - claims, ok := raw.(*casJWT.Claims) - if !ok || claims.OrgID == "" { - return ctx - } - return backend.WithRequestingOrg(ctx, claims.OrgID) -} - -// requestingOrgMiddleware is a kratos middleware that runs after the -// JWT middleware on unary gRPC requests and enriches ctx with the -// requesting org from the verified claims. -func requestingOrgMiddleware() middleware.Middleware { - return func(handler middleware.Handler) middleware.Handler { - return func(ctx context.Context, req any) (any, error) { - return handler(withRequestingOrgFromClaims(ctx), req) - } - } -} - -// requestingOrgHTTPMiddleware wraps an HTTP handler so the request -// context carries the requesting org. Apply it BETWEEN the JWT -// middleware (which populates the claims in ctx) and the actual -// handler. -func requestingOrgHTTPMiddleware(next nhttp.Handler) nhttp.Handler { - return nhttp.HandlerFunc(func(w nhttp.ResponseWriter, r *nhttp.Request) { - next.ServeHTTP(w, r.WithContext(withRequestingOrgFromClaims(r.Context()))) - }) -} diff --git a/app/artifact-cas/internal/server/grpc.go b/app/artifact-cas/internal/server/grpc.go index fc5ea2f7d..15a76be41 100644 --- a/app/artifact-cas/internal/server/grpc.go +++ b/app/artifact-cas/internal/server/grpc.go @@ -86,11 +86,6 @@ func NewGRPCServer(c *conf.Server, authConf *conf.Auth, byteService *service.Byt loadPublicKey(rawKey), jwtMiddleware.WithSigningMethod(casJWT.SigningMethod), jwtMiddleware.WithClaims(func() jwt.Claims { return &casJWT.Claims{} })), - // Runs after the JWT middleware on authenticated unary - // RPCs; stamps the requesting-org UUID from the verified - // claims onto ctx so managed CAS providers can scope STS - // sessions per tenant. - requestingOrgMiddleware(), ).Match(requireAuthentication()).Build(), ), @@ -188,9 +183,7 @@ func jwtAuthFunc(keyFunc jwt.Keyfunc, signingMethod jwt.SigningMethod) grpc_auth return nil, err } - ctx = jwtMiddleware.NewContext(ctx, claims) - // Same enrichment the unary chain does via requestingOrgMiddleware. - return withRequestingOrgFromClaims(ctx), nil + return jwtMiddleware.NewContext(ctx, claims), nil } } diff --git a/app/artifact-cas/internal/server/http.go b/app/artifact-cas/internal/server/http.go index 528f09dd9..3a0189a66 100644 --- a/app/artifact-cas/internal/server/http.go +++ b/app/artifact-cas/internal/server/http.go @@ -65,11 +65,7 @@ func NewHTTPServer(c *conf.Server, authConf *conf.Auth, downloadSvc *service.Dow srv := http.NewServer(opts...) - // AuthFromQueryParam verifies the JWT and stashes the claims in - // ctx; requestingOrgHTTPMiddleware then promotes the org-id claim - // into a value consumable by managed CAS providers (mirrors the - // gRPC chains). - downloadHandler := middlewares_http.AuthFromQueryParam(loadPublicKey(rawKey), claimsFunc(), casJWT.SigningMethod, requestingOrgHTTPMiddleware(downloadSvc)) + downloadHandler := middlewares_http.AuthFromQueryParam(loadPublicKey(rawKey), claimsFunc(), casJWT.SigningMethod, downloadSvc) srv.Handle(service.DownloadPath, CORSMiddleware(c.GetHttp().GetCors().GetAllowOrigins(), downloadHandler)) api.RegisterStatusServiceHTTPServer(srv, service.NewStatusService(Version, providers)) return srv, nil diff --git a/app/artifact-cas/internal/service/bytestream.go b/app/artifact-cas/internal/service/bytestream.go index ba462bfd0..025bebc34 100644 --- a/app/artifact-cas/internal/service/bytestream.go +++ b/app/artifact-cas/internal/service/bytestream.go @@ -64,7 +64,7 @@ func (s *ByteStreamService) Write(stream bytestream.ByteStream_WriteServer) erro defer span.End() // Get auth info and check that it's an uploader token - info, err := infoFromAuth(ctx) + info, err := casJWT.InfoFromAuth(ctx) if err != nil { return err } @@ -145,7 +145,7 @@ func (s *ByteStreamService) Read(req *bytestream.ReadRequest, stream bytestream. ctx := stream.Context() ctx, span := otelx.Start(ctx, byteStreamTracer, "ByteStreamService.Read") defer span.End() - info, err := infoFromAuth(ctx) + info, err := casJWT.InfoFromAuth(ctx) if err != nil { return err } diff --git a/app/artifact-cas/internal/service/download.go b/app/artifact-cas/internal/service/download.go index 6100f8082..afd0ac0b5 100644 --- a/app/artifact-cas/internal/service/download.go +++ b/app/artifact-cas/internal/service/download.go @@ -52,7 +52,7 @@ func (s *DownloadService) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ctx, span := otelx.Start(ctx, downloadTracer, "DownloadService.ServeHTTP") defer span.End() - auth, err := infoFromAuth(ctx) + auth, err := casJWT.InfoFromAuth(ctx) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return diff --git a/app/artifact-cas/internal/service/resource.go b/app/artifact-cas/internal/service/resource.go index 2c6924bd4..6360d307d 100644 --- a/app/artifact-cas/internal/service/resource.go +++ b/app/artifact-cas/internal/service/resource.go @@ -19,6 +19,7 @@ import ( "context" v1 "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" + casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" "github.com/chainloop-dev/chainloop/pkg/otelx" sl "github.com/chainloop-dev/chainloop/pkg/servicelogger" @@ -43,7 +44,7 @@ func (s *ResourceService) Describe(ctx context.Context, req *v1.ResourceServiceD ctx, span := otelx.Start(ctx, resourceTracer, "ResourceService.Describe") defer span.End() - info, err := infoFromAuth(ctx) + info, err := casJWT.InfoFromAuth(ctx) if err != nil { return nil, err } diff --git a/app/artifact-cas/internal/service/service.go b/app/artifact-cas/internal/service/service.go index 93f4e5ebd..acc1f453b 100644 --- a/app/artifact-cas/internal/service/service.go +++ b/app/artifact-cas/internal/service/service.go @@ -21,12 +21,10 @@ import ( "fmt" "syscall" - casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" "github.com/chainloop-dev/chainloop/pkg/servicelogger" kerrors "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/log" - "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/google/wire" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -49,10 +47,7 @@ func (s *commonService) loadBackend(ctx context.Context, providerType, secretID s.log.Infow("msg", "selected provider", "provider", providerType) - // The requesting-org enrichment for managed CAS providers is done - // at the auth boundary (see server.requestingOrgMiddleware / - // requestingOrgHTTPMiddleware), so ctx already carries everything - // the backend needs. + // Retrieve the OCI backend from where to download the file backend, err := p.FromCredentials(ctx, secretID) if err != nil { return nil, fmt.Errorf("failed to retrieve backend: %w", err) @@ -107,30 +102,3 @@ func isClientDisconnect(err error) bool { return false } - -// Extract the JWT claims from the context, note that the JWT verification has happened in the middleware -func infoFromAuth(ctx context.Context) (*casJWT.Claims, error) { - rawClaims, ok := jwt.FromContext(ctx) - if !ok { - return nil, kerrors.Unauthorized("cas", "missing authentication information") - } - - claims, ok := rawClaims.(*casJWT.Claims) - if !ok { - return nil, kerrors.Unauthorized("cas", "invalid authentication information") - } - - if claims.StoredSecretID == "" { - return nil, kerrors.Unauthorized("cas", "missing secret reference") - } - - if claims.BackendType == "" { - return nil, kerrors.Unauthorized("cas", "missing backend type") - } - - if claims.Role != casJWT.Uploader && claims.Role != casJWT.Downloader { - return nil, kerrors.Unauthorized("cas", "invalid role") - } - - return claims, nil -} diff --git a/app/artifact-cas/internal/service/service_test.go b/app/artifact-cas/internal/service/service_test.go index 149479355..acf5017bf 100644 --- a/app/artifact-cas/internal/service/service_test.go +++ b/app/artifact-cas/internal/service/service_test.go @@ -24,90 +24,15 @@ import ( "syscall" "testing" - 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" kerrors "github.com/go-kratos/kratos/v2/errors" - jwtm "github.com/go-kratos/kratos/v2/middleware/auth/jwt" - "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -func TestInfoFromAuth(t *testing.T) { - testCases := []struct { - name string - // input - claims jwt.Claims - wantErr bool - }{ - { - name: "valid claims downloader", - claims: &casJWT.Claims{ - Role: casJWT.Downloader, - StoredSecretID: "test", - BackendType: "backend-type", - }, - }, - { - name: "valid claims uploader", - claims: &casJWT.Claims{ - Role: casJWT.Uploader, - StoredSecretID: "test", - BackendType: "backend-type", - }, - }, - { - name: "invalid role", - claims: &casJWT.Claims{ - Role: "invalid", - StoredSecretID: "test", - BackendType: "backend-type", - }, - wantErr: true, - }, - { - name: "missing secretID", - claims: &casJWT.Claims{ - Role: "test", - BackendType: "backend-type", - }, - wantErr: true, - }, - { - name: "missing role", - claims: &casJWT.Claims{ - StoredSecretID: "test", - BackendType: "backend-type", - }, - wantErr: true, - }, - { - name: "missing backend type", - claims: &casJWT.Claims{ - StoredSecretID: "test", - Role: "test", - }, - wantErr: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - info, err := infoFromAuth(jwtm.NewContext(context.Background(), tc.claims)) - if tc.wantErr { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - assert.Equal(t, tc.claims, info) - }) - } -} - func TestLoadBackend(t *testing.T) { testCases := []struct { name string diff --git a/internal/robotaccount/cas/robotaccount.go b/internal/robotaccount/cas/robotaccount.go index 39b98ef7b..3a38570ef 100644 --- a/internal/robotaccount/cas/robotaccount.go +++ b/internal/robotaccount/cas/robotaccount.go @@ -16,12 +16,15 @@ package robotaccount import ( + "context" "crypto/ecdsa" "errors" "fmt" "os" "time" + kerrors "github.com/go-kratos/kratos/v2/errors" + kratosjwt "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/golang-jwt/jwt/v4" ) @@ -40,10 +43,7 @@ type Claims struct { MaxBytes int64 `json:"maxbytes"` // max bytes to upload // OrgID identifies the authenticated org this token was minted for. // Required for managed providers (AWS-S3-ACCESS-POINT) that need to - // scope per-tenant STS sessions; carried as a separate claim from - // StoredSecretID so the binding can't be tampered with by rewriting - // just the secret store. Empty for legacy tokens or providers that - // don't need per-tenant attribution. + // scope per-tenant STS sessions; OrgID string `json:"org-id,omitempty"` } @@ -186,3 +186,30 @@ func (c *Claims) CheckRole(r Role) error { return nil } + +// InfoFromAuth extracts the JWT claims from the context, note that the JWT verification has happened in the middleware +func InfoFromAuth(ctx context.Context) (*Claims, error) { + rawClaims, ok := kratosjwt.FromContext(ctx) + if !ok { + return nil, kerrors.Unauthorized("cas", "missing authentication information") + } + + claims, ok := rawClaims.(*Claims) + if !ok { + return nil, kerrors.Unauthorized("cas", "invalid authentication information") + } + + if claims.StoredSecretID == "" { + return nil, kerrors.Unauthorized("cas", "missing secret reference") + } + + if claims.BackendType == "" { + return nil, kerrors.Unauthorized("cas", "missing backend type") + } + + if claims.Role != Uploader && claims.Role != Downloader { + return nil, kerrors.Unauthorized("cas", "invalid role") + } + + return claims, nil +} diff --git a/internal/robotaccount/cas/robotaccount_test.go b/internal/robotaccount/cas/robotaccount_test.go index 6e83f8e32..d627a5633 100644 --- a/internal/robotaccount/cas/robotaccount_test.go +++ b/internal/robotaccount/cas/robotaccount_test.go @@ -16,10 +16,12 @@ package robotaccount import ( + "context" "os" "testing" "time" + jwtmiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -177,3 +179,75 @@ func loadPublicKey(rawKey []byte) jwt.Keyfunc { return jwt.ParseECPublicKeyFromPEM(rawKey) } } + +func TestInfoFromAuth(t *testing.T) { + testCases := []struct { + name string + // input + claims jwt.Claims + wantErr bool + }{ + { + name: "valid claims downloader", + claims: &Claims{ + Role: Downloader, + StoredSecretID: "test", + BackendType: "backend-type", + }, + }, + { + name: "valid claims uploader", + claims: &Claims{ + Role: Uploader, + StoredSecretID: "test", + BackendType: "backend-type", + }, + }, + { + name: "invalid role", + claims: &Claims{ + Role: "invalid", + StoredSecretID: "test", + BackendType: "backend-type", + }, + wantErr: true, + }, + { + name: "missing secretID", + claims: &Claims{ + Role: "test", + BackendType: "backend-type", + }, + wantErr: true, + }, + { + name: "missing role", + claims: &Claims{ + StoredSecretID: "test", + BackendType: "backend-type", + }, + wantErr: true, + }, + { + name: "missing backend type", + claims: &Claims{ + StoredSecretID: "test", + Role: "test", + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + info, err := InfoFromAuth(jwtmiddleware.NewContext(context.Background(), tc.claims)) + if tc.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.claims, info) + }) + } +} diff --git a/pkg/blobmanager/backend.go b/pkg/blobmanager/backend.go index 6d295932f..37652c009 100644 --- a/pkg/blobmanager/backend.go +++ b/pkg/blobmanager/backend.go @@ -70,35 +70,3 @@ type Providers map[string]Provider func DetectedMediaType(b []byte) types.MediaType { return types.MediaType(strings.Split(http.DetectContentType(b), ";")[0]) } - -// requestingOrgCtxKey is unexported so callers must go through -// WithRequestingOrg / RequestingOrgFromContext; no risk of accidental -// collision with another package's keys. -type requestingOrgCtxKey struct{} - -// WithRequestingOrg returns a derived context that carries the -// authenticated requesting organization's UUID. Managed backends -// (currently AWS-S3-ACCESS-POINT) consume this value to scope per- -// tenant STS sessions; non-managed backends ignore it. -// -// The value MUST come from the verified caller identity (e.g. a CAS -// JWT claim), NOT from a resolved CASBackend row or its secret blob. -// The whole secret-tampering defense for managed CAS depends on this -// being a source the attacker can't rewrite together with the secret -// store. -// -// Callers typically set this once at the auth boundary (the CAS -// server's JWT middleware) and let the value flow through ctx into -// the backend's request handlers. -func WithRequestingOrg(ctx context.Context, orgUUID string) context.Context { - return context.WithValue(ctx, requestingOrgCtxKey{}, orgUUID) -} - -// RequestingOrgFromContext extracts the requesting org UUID previously -// stamped by WithRequestingOrg. Empty string means "no caller set the -// key" — backends that need a tenant identifier (e.g. managed CAS) -// should treat that as a fail-closed condition. -func RequestingOrgFromContext(ctx context.Context) string { - v, _ := ctx.Value(requestingOrgCtxKey{}).(string) - return v -} diff --git a/pkg/blobmanager/backend_test.go b/pkg/blobmanager/backend_test.go deleted file mode 100644 index 17cab7414..000000000 --- a/pkg/blobmanager/backend_test.go +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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 backend_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - - backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" -) - -// TestWithRequestingOrg_RoundTrip pins the ctx-key contract relied on -// by managed CAS providers (e.g. s3accesspoint), which read the org -// UUID via backend.RequestingOrgFromContext to mint per-tenant STS -// sessions. Changing the key type or accessor without updating those -// providers would silently break the fail-closed path. -func TestWithRequestingOrg_RoundTrip(t *testing.T) { - // Empty by default. - assert.Empty(t, backend.RequestingOrgFromContext(context.Background())) - - ctx := backend.WithRequestingOrg(context.Background(), "org-abc") - assert.Equal(t, "org-abc", backend.RequestingOrgFromContext(ctx)) - - // Overwrite uses the most recent value — important so a middleware - // that sets the org isn't silently overridden by a stale value - // further down the stack. - ctx = backend.WithRequestingOrg(ctx, "org-xyz") - assert.Equal(t, "org-xyz", backend.RequestingOrgFromContext(ctx)) -} diff --git a/pkg/blobmanager/s3accesspoint/backend.go b/pkg/blobmanager/s3accesspoint/backend.go index 12b23d1b0..e823fe88a 100644 --- a/pkg/blobmanager/s3accesspoint/backend.go +++ b/pkg/blobmanager/s3accesspoint/backend.go @@ -33,6 +33,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/smithy-go" pb "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" + robotaccount "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" ) @@ -130,11 +131,14 @@ func NewBackend(ctx context.Context, cfg *Config, creds *Credentials) (*Backend, // invariant the credentials provider enforces, just surfaced earlier // with a clearer error. func (b *Backend) keyFor(ctx context.Context, digest string) (string, error) { - orgUUID := backend.RequestingOrgFromContext(ctx) - if orgUUID == "" { + claims, err := robotaccount.InfoFromAuth(ctx) + if err != nil { + return "", err + } + if claims.OrgID == "" { return "", ErrMissingRequestingOrg } - return fmt.Sprintf("%s/sha256:%s", orgUUID, digest), nil + return fmt.Sprintf("%s/sha256:%s", claims.OrgID, digest), nil } func (b *Backend) Exists(ctx context.Context, digest string) (bool, error) { @@ -236,12 +240,15 @@ func (b *Backend) Download(ctx context.Context, w io.Writer, digest string) erro // s3 backend's variant this MUST be invoked with a context carrying // WithRequestingOrg; otherwise it fails closed. func (b *Backend) CheckWritePermissions(ctx context.Context) error { - orgUUID := backend.RequestingOrgFromContext(ctx) - if orgUUID == "" { + info, err := robotaccount.InfoFromAuth(ctx) + if err != nil { + return err + } + if info.OrgID == "" { return ErrMissingRequestingOrg } const testObject = "healthcheck" - key := fmt.Sprintf("%s/%s", orgUUID, testObject) + key := fmt.Sprintf("%s/%s", info.OrgID, testObject) if _, err := b.s3Client.PutObject(ctx, &s3.PutObjectInput{ Body: strings.NewReader("healthcheckdata"), @@ -288,8 +295,11 @@ type sessionCredentialsProvider struct { // be cheap to call (the cache wrapper deduplicates concurrent misses and // caches valid creds until ExpiresIn). func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { - orgUUID := backend.RequestingOrgFromContext(ctx) - if orgUUID == "" { + info, err := robotaccount.InfoFromAuth(ctx) + if err != nil { + return aws.Credentials{}, err + } + if info.OrgID == "" { return aws.Credentials{}, ErrMissingRequestingOrg } @@ -310,7 +320,7 @@ func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credenti // straight from ctx — same source as the session name — so a // tampered AccessPointARN in the secret blob can't widen the prefix // scope to escape into another tenant's namespace. - sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN, orgUUID) + sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN, info.OrgID) durSecs := int32(p.sessionDuration / time.Second) if durSecs <= 0 { @@ -319,12 +329,12 @@ func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credenti out, err := p.stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ RoleArn: aws.String(p.baseRoleARN), - RoleSessionName: aws.String(roleSessionName(orgUUID)), + RoleSessionName: aws.String(roleSessionName(info.OrgID)), Policy: aws.String(sessionPolicy), DurationSeconds: aws.Int32(durSecs), }) if err != nil { - return aws.Credentials{}, fmt.Errorf("sts:AssumeRole for org %s: %w", orgUUID, err) + return aws.Credentials{}, fmt.Errorf("sts:AssumeRole for org %s: %w", info.OrgID, err) } if out.Credentials == nil { return aws.Credentials{}, errors.New("sts:AssumeRole returned no credentials") From 333a45e032630d86e3ee05c5c1d8b3192570137c Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 18:06:51 +0200 Subject: [PATCH 11/18] fix tests Signed-off-by: Jose I. Paris --- pkg/blobmanager/s3accesspoint/backend_test.go | 19 ++++++++++++------- pkg/blobmanager/s3accesspoint/provider.go | 3 --- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pkg/blobmanager/s3accesspoint/backend_test.go b/pkg/blobmanager/s3accesspoint/backend_test.go index ea476a020..bf0f577c6 100644 --- a/pkg/blobmanager/s3accesspoint/backend_test.go +++ b/pkg/blobmanager/s3accesspoint/backend_test.go @@ -22,7 +22,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" pb "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" - backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" + robotaccount "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" + jwtmiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -38,7 +39,7 @@ import ( // credential chain. func TestBackend_FailClosedWithoutRequestingOrg(t *testing.T) { b := newTestBackend(t) - ctx := context.Background() // intentionally no backend.WithRequestingOrg + ctx := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) t.Run("upload", func(t *testing.T) { err := b.Upload(ctx, bytes.NewReader([]byte("data")), @@ -82,16 +83,19 @@ func TestBackend_KeyDerivedFromRequestingOrg(t *testing.T) { }} digest := "deadbeef" - keyA, err := b.keyFor(backend.WithRequestingOrg(context.Background(), "org-A"), digest) + ctxA := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{OrgID: "org-A", StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) + keyA, err := b.keyFor(ctxA, digest) require.NoError(t, err) - keyB, err := b.keyFor(backend.WithRequestingOrg(context.Background(), "org-B"), digest) + ctxB := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{OrgID: "org-B", StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) + keyB, err := b.keyFor(ctxB, digest) require.NoError(t, err) assert.Equal(t, "org-A/sha256:deadbeef", keyA) assert.Equal(t, "org-B/sha256:deadbeef", keyB) assert.NotEqual(t, keyA, keyB, "same digest must produce distinct keys across tenants") - _, err = b.keyFor(context.Background(), digest) + ctx := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) + _, err = b.keyFor(ctx, digest) require.ErrorIs(t, err, ErrMissingRequestingOrg) } @@ -144,7 +148,7 @@ func TestSessionCredentialsProvider_DevModeShortCircuit(t *testing.T) { } t.Run("returns ambient credentials when org is set", func(t *testing.T) { - ctx := backend.WithRequestingOrg(context.Background(), "org-A") + ctx := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{OrgID: "org-A", StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) got, err := p.Retrieve(ctx) require.NoError(t, err) assert.Equal(t, "AKDEV", got.AccessKeyID) @@ -153,7 +157,8 @@ func TestSessionCredentialsProvider_DevModeShortCircuit(t *testing.T) { t.Run("still fails closed without requesting org", func(t *testing.T) { ambient.calls = 0 - _, err := p.Retrieve(context.Background()) + _, err := p.Retrieve(jwtmiddleware.NewContext(context.Background(), + &robotaccount.Claims{StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader})) require.ErrorIs(t, err, ErrMissingRequestingOrg) assert.Equal(t, 0, ambient.calls, "ambient provider must not be hit when org is missing") }) diff --git a/pkg/blobmanager/s3accesspoint/provider.go b/pkg/blobmanager/s3accesspoint/provider.go index 4c4f31ae1..d5a41d4f9 100644 --- a/pkg/blobmanager/s3accesspoint/provider.go +++ b/pkg/blobmanager/s3accesspoint/provider.go @@ -131,9 +131,6 @@ func (c *Config) Validate() error { // untamperable source, so a secrets-store compromise that rewrites this // blob still can't reroute a tenant's writes into another tenant's // namespace. -// -// The platform reconciler is responsible for writing this blob in lockstep -// with the AWS-side AP creation and policy. type Credentials struct { // AccessPointARN, e.g. // arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org- From a1a02e69b991f362826774a2558b623d663cfdbf Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 19:10:57 +0200 Subject: [PATCH 12/18] refactor(s3accesspoint): collapse deployment config into per-tenant secret Move BaseRoleARN and Region from the deployment-level config into the per-tenant Credentials blob, drop SessionDuration in favour of a 1h constant, and read the dev-mode bypass from CHAINLOOP_S3_ACCESS_POINT_DEV_MODE instead of config. The ManagedCASBackends proto blocks, the corresponding wire plumbing, and the loader Options surface are all gone. The provider is now registered unconditionally; on-prem deployments without managed CAS simply never have managed rows. A single chainloop install can also serve tenants across multiple AWS accounts without a config change since BaseRoleARN is per-secret. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- app/artifact-cas/cmd/wire.go | 23 - app/artifact-cas/cmd/wire_gen.go | 25 +- app/artifact-cas/configs/config.devel.yaml | 14 - app/artifact-cas/internal/conf/conf.pb.go | 246 +++------- app/artifact-cas/internal/conf/conf.proto | 21 - app/controlplane/cmd/wire.go | 24 +- app/controlplane/cmd/wire_gen.go | 25 +- app/controlplane/configs/config.devel.yaml | 16 - .../conf/controlplane/config/v1/conf.pb.go | 440 ++++++------------ .../conf/controlplane/config/v1/conf.proto | 50 -- pkg/blobmanager/loader/loader.go | 58 +-- pkg/blobmanager/loader/loader_test.go | 38 +- pkg/blobmanager/s3accesspoint/backend.go | 40 +- pkg/blobmanager/s3accesspoint/backend_test.go | 8 +- pkg/blobmanager/s3accesspoint/provider.go | 146 +++--- .../s3accesspoint/provider_test.go | 114 ++--- 16 files changed, 326 insertions(+), 962 deletions(-) diff --git a/app/artifact-cas/cmd/wire.go b/app/artifact-cas/cmd/wire.go index b9f2f670e..d9c2d823f 100644 --- a/app/artifact-cas/cmd/wire.go +++ b/app/artifact-cas/cmd/wire.go @@ -25,7 +25,6 @@ import ( "github.com/chainloop-dev/chainloop/app/artifact-cas/internal/server" "github.com/chainloop-dev/chainloop/app/artifact-cas/internal/service" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" - "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/credentials" "github.com/go-kratos/kratos/v2/log" "github.com/google/wire" @@ -38,8 +37,6 @@ func wireApp(*conf.Bootstrap, *conf.Server, *conf.Auth, credentials.Reader, log. server.ProviderSet, service.ProviderSet, loader.LoadProviders, - newLoaderOptions, - wire.FieldsOf(new(*conf.Bootstrap), "ManagedCasBackends"), newApp, serviceOpts, newProtoValidator, @@ -47,26 +44,6 @@ func wireApp(*conf.Bootstrap, *conf.Server, *conf.Auth, credentials.Reader, log. ) } -// newLoaderOptions builds the loader.Options struct from the deployment -// Bootstrap. When `managed_cas_backends.s3_access_point` is absent (the -// common case for on-prem) S3AccessPoint stays nil and the provider is -// not registered, leaving the binary's behaviour identical to the -// pre-managed-CAS world. -func newLoaderOptions(in *conf.ManagedCASBackends, l log.Logger) *loader.Options { - opts := &loader.Options{Logger: l} - if in == nil || in.GetS3AccessPoint() == nil { - return opts - } - ap := in.GetS3AccessPoint() - opts.S3AccessPoint = &s3accesspoint.Config{ - BaseRoleARN: ap.GetBaseRoleArn(), - Region: ap.GetRegion(), - SessionDuration: ap.GetSessionDuration().AsDuration(), - DevModeUseAmbientCredentials: ap.GetDevModeUseAmbientCredentials(), - } - return opts -} - func serviceOpts(l log.Logger) []service.NewOpt { return []service.NewOpt{ service.WithLogger(l), diff --git a/app/artifact-cas/cmd/wire_gen.go b/app/artifact-cas/cmd/wire_gen.go index ddd9e0061..07dba05dc 100644 --- a/app/artifact-cas/cmd/wire_gen.go +++ b/app/artifact-cas/cmd/wire_gen.go @@ -11,7 +11,6 @@ import ( "github.com/chainloop-dev/chainloop/app/artifact-cas/internal/server" "github.com/chainloop-dev/chainloop/app/artifact-cas/internal/service" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" - "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/credentials" "github.com/go-kratos/kratos/v2/log" ) @@ -24,9 +23,7 @@ import ( // wireApp init kratos application. func wireApp(bootstrap *conf.Bootstrap, confServer *conf.Server, auth *conf.Auth, reader credentials.Reader, logger log.Logger) (*app, func(), error) { - managedCASBackends := bootstrap.ManagedCasBackends - options := newLoaderOptions(managedCASBackends, logger) - providers := loader.LoadProviders(reader, options) + providers := loader.LoadProviders(reader) v := serviceOpts(logger) byteStreamService := service.NewByteStreamService(providers, v...) resourceService := service.NewResourceService(providers, v...) @@ -59,26 +56,6 @@ func wireApp(bootstrap *conf.Bootstrap, confServer *conf.Server, auth *conf.Auth // wire.go: -// newLoaderOptions builds the loader.Options struct from the deployment -// Bootstrap. When `managed_cas_backends.s3_access_point` is absent (the -// common case for on-prem) S3AccessPoint stays nil and the provider is -// not registered, leaving the binary's behaviour identical to the -// pre-managed-CAS world. -func newLoaderOptions(in *conf.ManagedCASBackends, l log.Logger) *loader.Options { - opts := &loader.Options{Logger: l} - if in == nil || in.GetS3AccessPoint() == nil { - return opts - } - ap := in.GetS3AccessPoint() - opts.S3AccessPoint = &s3accesspoint.Config{ - BaseRoleARN: ap.GetBaseRoleArn(), - Region: ap.GetRegion(), - SessionDuration: ap.GetSessionDuration().AsDuration(), - DevModeUseAmbientCredentials: ap.GetDevModeUseAmbientCredentials(), - } - return opts -} - func serviceOpts(l log.Logger) []service.NewOpt { return []service.NewOpt{service.WithLogger(l)} } diff --git a/app/artifact-cas/configs/config.devel.yaml b/app/artifact-cas/configs/config.devel.yaml index e827bee25..63c53c6cd 100644 --- a/app/artifact-cas/configs/config.devel.yaml +++ b/app/artifact-cas/configs/config.devel.yaml @@ -39,17 +39,3 @@ observability: auth: public_key_path: ${PUBLIC_KEY_PATH:../../devel/devkeys/cas.pub} - -# Optional managed CAS provider (S3 Access Points). Mirrors the -# controlplane's managed_cas_backends block — both binaries must agree -# on the settings since each independently instantiates the provider. -# Leave commented out for on-prem deployments that don't use managed CAS. -# managed_cas_backends: -# s3_access_point: -# base_role_arn: arn:aws:iam::123456789012:role/chainloop-cas-tenant -# region: us-east-1 -# session_duration: 1h -# # DEV ONLY: bypass sts:AssumeRole and use whatever AWS identity the -# # SDK default credential chain produces (env vars, ~/.aws/credentials, -# # IRSA, …). Skips per-tenant isolation; never enable in production. -# # dev_mode_use_ambient_credentials: true diff --git a/app/artifact-cas/internal/conf/conf.pb.go b/app/artifact-cas/internal/conf/conf.pb.go index ffe0ff585..a29ee28e9 100644 --- a/app/artifact-cas/internal/conf/conf.pb.go +++ b/app/artifact-cas/internal/conf/conf.pb.go @@ -44,10 +44,6 @@ type Bootstrap struct { Auth *Auth `protobuf:"bytes,2,opt,name=auth,proto3" json:"auth,omitempty"` Observability *Bootstrap_Observability `protobuf:"bytes,3,opt,name=observability,proto3" json:"observability,omitempty"` CredentialsService *v1.Credentials `protobuf:"bytes,4,opt,name=credentials_service,json=credentialsService,proto3" json:"credentials_service,omitempty"` - // Deployment-level configuration for managed CAS storage backends - // (provisioned and operated by Chainloop, not by tenants). Optional — - // omitting a sub-block keeps the corresponding provider unregistered. - ManagedCasBackends *ManagedCASBackends `protobuf:"bytes,5,opt,name=managed_cas_backends,json=managedCasBackends,proto3" json:"managed_cas_backends,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -110,13 +106,6 @@ func (x *Bootstrap) GetCredentialsService() *v1.Credentials { return nil } -func (x *Bootstrap) GetManagedCasBackends() *ManagedCASBackends { - if x != nil { - return x.ManagedCasBackends - } - return nil -} - type Server struct { state protoimpl.MessageState `protogen:"open.v1"` // Regular HTTP endpoint @@ -180,54 +169,6 @@ func (x *Server) GetHttpMetrics() *Server_HTTP { return nil } -// ManagedCASBackends mirrors the controlplane's `ManagedCASBackends` -// block. Defined independently here so the artifact-cas binary doesn't -// depend on the controlplane's protobuf package. Keep field numbering -// in sync across both definitions. -type ManagedCASBackends struct { - state protoimpl.MessageState `protogen:"open.v1"` - S3AccessPoint *ManagedCASBackends_S3AccessPoint `protobuf:"bytes,1,opt,name=s3_access_point,json=s3AccessPoint,proto3" json:"s3_access_point,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ManagedCASBackends) Reset() { - *x = ManagedCASBackends{} - mi := &file_conf_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ManagedCASBackends) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ManagedCASBackends) ProtoMessage() {} - -func (x *ManagedCASBackends) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ManagedCASBackends.ProtoReflect.Descriptor instead. -func (*ManagedCASBackends) Descriptor() ([]byte, []int) { - return file_conf_proto_rawDescGZIP(), []int{2} -} - -func (x *ManagedCASBackends) GetS3AccessPoint() *ManagedCASBackends_S3AccessPoint { - if x != nil { - return x.S3AccessPoint - } - return nil -} - type Auth struct { state protoimpl.MessageState `protogen:"open.v1"` // Public key used to verify the received JWT token @@ -243,7 +184,7 @@ type Auth struct { func (x *Auth) Reset() { *x = Auth{} - mi := &file_conf_proto_msgTypes[3] + mi := &file_conf_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -255,7 +196,7 @@ func (x *Auth) String() string { func (*Auth) ProtoMessage() {} func (x *Auth) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[3] + mi := &file_conf_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -268,7 +209,7 @@ func (x *Auth) ProtoReflect() protoreflect.Message { // Deprecated: Use Auth.ProtoReflect.Descriptor instead. func (*Auth) Descriptor() ([]byte, []int) { - return file_conf_proto_rawDescGZIP(), []int{3} + return file_conf_proto_rawDescGZIP(), []int{2} } // Deprecated: Marked as deprecated in conf.proto. @@ -296,7 +237,7 @@ type Bootstrap_Observability struct { func (x *Bootstrap_Observability) Reset() { *x = Bootstrap_Observability{} - mi := &file_conf_proto_msgTypes[4] + mi := &file_conf_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -308,7 +249,7 @@ func (x *Bootstrap_Observability) String() string { func (*Bootstrap_Observability) ProtoMessage() {} func (x *Bootstrap_Observability) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[4] + mi := &file_conf_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -349,7 +290,7 @@ type Bootstrap_Observability_Sentry struct { func (x *Bootstrap_Observability_Sentry) Reset() { *x = Bootstrap_Observability_Sentry{} - mi := &file_conf_proto_msgTypes[5] + mi := &file_conf_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -361,7 +302,7 @@ func (x *Bootstrap_Observability_Sentry) String() string { func (*Bootstrap_Observability_Sentry) ProtoMessage() {} func (x *Bootstrap_Observability_Sentry) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[5] + mi := &file_conf_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -408,7 +349,7 @@ type Bootstrap_Observability_Tracing struct { func (x *Bootstrap_Observability_Tracing) Reset() { *x = Bootstrap_Observability_Tracing{} - mi := &file_conf_proto_msgTypes[6] + mi := &file_conf_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -420,7 +361,7 @@ func (x *Bootstrap_Observability_Tracing) String() string { func (*Bootstrap_Observability_Tracing) ProtoMessage() {} func (x *Bootstrap_Observability_Tracing) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[6] + mi := &file_conf_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -473,7 +414,7 @@ type Server_CORS struct { func (x *Server_CORS) Reset() { *x = Server_CORS{} - mi := &file_conf_proto_msgTypes[7] + mi := &file_conf_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -485,7 +426,7 @@ func (x *Server_CORS) String() string { func (*Server_CORS) ProtoMessage() {} func (x *Server_CORS) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[7] + mi := &file_conf_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -521,7 +462,7 @@ type Server_HTTP struct { func (x *Server_HTTP) Reset() { *x = Server_HTTP{} - mi := &file_conf_proto_msgTypes[8] + mi := &file_conf_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -533,7 +474,7 @@ func (x *Server_HTTP) String() string { func (*Server_HTTP) ProtoMessage() {} func (x *Server_HTTP) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[8] + mi := &file_conf_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -588,7 +529,7 @@ type Server_TLS struct { func (x *Server_TLS) Reset() { *x = Server_TLS{} - mi := &file_conf_proto_msgTypes[9] + mi := &file_conf_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -600,7 +541,7 @@ func (x *Server_TLS) String() string { func (*Server_TLS) ProtoMessage() {} func (x *Server_TLS) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[9] + mi := &file_conf_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -642,7 +583,7 @@ type Server_GRPC struct { func (x *Server_GRPC) Reset() { *x = Server_GRPC{} - mi := &file_conf_proto_msgTypes[10] + mi := &file_conf_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -654,7 +595,7 @@ func (x *Server_GRPC) String() string { func (*Server_GRPC) ProtoMessage() {} func (x *Server_GRPC) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[10] + mi := &file_conf_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -698,88 +639,17 @@ func (x *Server_GRPC) GetTlsConfig() *Server_TLS { return nil } -type ManagedCASBackends_S3AccessPoint struct { - state protoimpl.MessageState `protogen:"open.v1"` - BaseRoleArn string `protobuf:"bytes,1,opt,name=base_role_arn,json=baseRoleArn,proto3" json:"base_role_arn,omitempty"` - Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` - SessionDuration *durationpb.Duration `protobuf:"bytes,3,opt,name=session_duration,json=sessionDuration,proto3" json:"session_duration,omitempty"` - // DEV ONLY — see controlplane proto for full doc. Bypasses - // sts:AssumeRole and uses the pod's ambient AWS identity directly. - DevModeUseAmbientCredentials bool `protobuf:"varint,4,opt,name=dev_mode_use_ambient_credentials,json=devModeUseAmbientCredentials,proto3" json:"dev_mode_use_ambient_credentials,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ManagedCASBackends_S3AccessPoint) Reset() { - *x = ManagedCASBackends_S3AccessPoint{} - mi := &file_conf_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ManagedCASBackends_S3AccessPoint) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ManagedCASBackends_S3AccessPoint) ProtoMessage() {} - -func (x *ManagedCASBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { - mi := &file_conf_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ManagedCASBackends_S3AccessPoint.ProtoReflect.Descriptor instead. -func (*ManagedCASBackends_S3AccessPoint) Descriptor() ([]byte, []int) { - return file_conf_proto_rawDescGZIP(), []int{2, 0} -} - -func (x *ManagedCASBackends_S3AccessPoint) GetBaseRoleArn() string { - if x != nil { - return x.BaseRoleArn - } - return "" -} - -func (x *ManagedCASBackends_S3AccessPoint) GetRegion() string { - if x != nil { - return x.Region - } - return "" -} - -func (x *ManagedCASBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { - if x != nil { - return x.SessionDuration - } - return nil -} - -func (x *ManagedCASBackends_S3AccessPoint) GetDevModeUseAmbientCredentials() bool { - if x != nil { - return x.DevModeUseAmbientCredentials - } - return false -} - var File_conf_proto protoreflect.FileDescriptor const file_conf_proto_rawDesc = "" + "\n" + "\n" + - "conf.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xfe\x04\n" + + "conf.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xb7\x04\n" + "\tBootstrap\x12\x1f\n" + "\x06server\x18\x01 \x01(\v2\a.ServerR\x06server\x12\x19\n" + "\x04auth\x18\x02 \x01(\v2\x05.AuthR\x04auth\x12>\n" + "\robservability\x18\x03 \x01(\v2\x18.Bootstrap.ObservabilityR\robservability\x12L\n" + - "\x13credentials_service\x18\x04 \x01(\v2\x1b.credentials.v1.CredentialsR\x12credentialsService\x12E\n" + - "\x14managed_cas_backends\x18\x05 \x01(\v2\x13.ManagedCASBackendsR\x12managedCasBackends\x1a\xdf\x02\n" + + "\x13credentials_service\x18\x04 \x01(\v2\x1b.credentials.v1.CredentialsR\x12credentialsService\x1a\xdf\x02\n" + "\rObservability\x127\n" + "\x06sentry\x18\x01 \x01(\v2\x1f.Bootstrap.Observability.SentryR\x06sentry\x12:\n" + "\atracing\x18\x02 \x01(\v2 .Bootstrap.Observability.TracingR\atracing\x1a<\n" + @@ -812,14 +682,7 @@ const file_conf_proto_rawDesc = "" + "\x04addr\x18\x02 \x01(\tR\x04addr\x123\n" + "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12*\n" + "\n" + - "tls_config\x18\x04 \x01(\v2\v.Server.TLSR\ttlsConfig\"\xbb\x02\n" + - "\x12ManagedCASBackends\x12I\n" + - "\x0fs3_access_point\x18\x01 \x01(\v2!.ManagedCASBackends.S3AccessPointR\rs3AccessPoint\x1a\xd9\x01\n" + - "\rS3AccessPoint\x12\"\n" + - "\rbase_role_arn\x18\x01 \x01(\tR\vbaseRoleArn\x12\x16\n" + - "\x06region\x18\x02 \x01(\tR\x06region\x12D\n" + - "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\x12F\n" + - " dev_mode_use_ambient_credentials\x18\x04 \x01(\bR\x1cdevModeUseAmbientCredentials\"t\n" + + "tls_config\x18\x04 \x01(\v2\v.Server.TLSR\ttlsConfig\"t\n" + "\x04Auth\x12D\n" + "\x1drobot_account_public_key_path\x18\x01 \x01(\tB\x02\x18\x01R\x19robotAccountPublicKeyPath\x12&\n" + "\x0fpublic_key_path\x18\x02 \x01(\tR\rpublicKeyPathBHZFgithub.com/chainloop-dev/chainloop/app/artifact-cas/internal/conf;confb\x06proto3" @@ -836,45 +699,40 @@ func file_conf_proto_rawDescGZIP() []byte { return file_conf_proto_rawDescData } -var file_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_conf_proto_goTypes = []any{ - (*Bootstrap)(nil), // 0: Bootstrap - (*Server)(nil), // 1: Server - (*ManagedCASBackends)(nil), // 2: ManagedCASBackends - (*Auth)(nil), // 3: Auth - (*Bootstrap_Observability)(nil), // 4: Bootstrap.Observability - (*Bootstrap_Observability_Sentry)(nil), // 5: Bootstrap.Observability.Sentry - (*Bootstrap_Observability_Tracing)(nil), // 6: Bootstrap.Observability.Tracing - (*Server_CORS)(nil), // 7: Server.CORS - (*Server_HTTP)(nil), // 8: Server.HTTP - (*Server_TLS)(nil), // 9: Server.TLS - (*Server_GRPC)(nil), // 10: Server.GRPC - (*ManagedCASBackends_S3AccessPoint)(nil), // 11: ManagedCASBackends.S3AccessPoint - (*v1.Credentials)(nil), // 12: credentials.v1.Credentials - (*durationpb.Duration)(nil), // 13: google.protobuf.Duration + (*Bootstrap)(nil), // 0: Bootstrap + (*Server)(nil), // 1: Server + (*Auth)(nil), // 2: Auth + (*Bootstrap_Observability)(nil), // 3: Bootstrap.Observability + (*Bootstrap_Observability_Sentry)(nil), // 4: Bootstrap.Observability.Sentry + (*Bootstrap_Observability_Tracing)(nil), // 5: Bootstrap.Observability.Tracing + (*Server_CORS)(nil), // 6: Server.CORS + (*Server_HTTP)(nil), // 7: Server.HTTP + (*Server_TLS)(nil), // 8: Server.TLS + (*Server_GRPC)(nil), // 9: Server.GRPC + (*v1.Credentials)(nil), // 10: credentials.v1.Credentials + (*durationpb.Duration)(nil), // 11: google.protobuf.Duration } var file_conf_proto_depIdxs = []int32{ 1, // 0: Bootstrap.server:type_name -> Server - 3, // 1: Bootstrap.auth:type_name -> Auth - 4, // 2: Bootstrap.observability:type_name -> Bootstrap.Observability - 12, // 3: Bootstrap.credentials_service:type_name -> credentials.v1.Credentials - 2, // 4: Bootstrap.managed_cas_backends:type_name -> ManagedCASBackends - 8, // 5: Server.http:type_name -> Server.HTTP - 10, // 6: Server.grpc:type_name -> Server.GRPC - 8, // 7: Server.http_metrics:type_name -> Server.HTTP - 11, // 8: ManagedCASBackends.s3_access_point:type_name -> ManagedCASBackends.S3AccessPoint - 5, // 9: Bootstrap.Observability.sentry:type_name -> Bootstrap.Observability.Sentry - 6, // 10: Bootstrap.Observability.tracing:type_name -> Bootstrap.Observability.Tracing - 13, // 11: Server.HTTP.timeout:type_name -> google.protobuf.Duration - 7, // 12: Server.HTTP.cors:type_name -> Server.CORS - 13, // 13: Server.GRPC.timeout:type_name -> google.protobuf.Duration - 9, // 14: Server.GRPC.tls_config:type_name -> Server.TLS - 13, // 15: ManagedCASBackends.S3AccessPoint.session_duration:type_name -> google.protobuf.Duration - 16, // [16:16] is the sub-list for method output_type - 16, // [16:16] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 2, // 1: Bootstrap.auth:type_name -> Auth + 3, // 2: Bootstrap.observability:type_name -> Bootstrap.Observability + 10, // 3: Bootstrap.credentials_service:type_name -> credentials.v1.Credentials + 7, // 4: Server.http:type_name -> Server.HTTP + 9, // 5: Server.grpc:type_name -> Server.GRPC + 7, // 6: Server.http_metrics:type_name -> Server.HTTP + 4, // 7: Bootstrap.Observability.sentry:type_name -> Bootstrap.Observability.Sentry + 5, // 8: Bootstrap.Observability.tracing:type_name -> Bootstrap.Observability.Tracing + 11, // 9: Server.HTTP.timeout:type_name -> google.protobuf.Duration + 6, // 10: Server.HTTP.cors:type_name -> Server.CORS + 11, // 11: Server.GRPC.timeout:type_name -> google.protobuf.Duration + 8, // 12: Server.GRPC.tls_config:type_name -> Server.TLS + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_conf_proto_init() } @@ -882,14 +740,14 @@ func file_conf_proto_init() { if File_conf_proto != nil { return } - file_conf_proto_msgTypes[6].OneofWrappers = []any{} + file_conf_proto_msgTypes[5].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_conf_proto_rawDesc), len(file_conf_proto_rawDesc)), NumEnums: 0, - NumMessages: 12, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/app/artifact-cas/internal/conf/conf.proto b/app/artifact-cas/internal/conf/conf.proto index a75aa71f4..63c26a9bc 100644 --- a/app/artifact-cas/internal/conf/conf.proto +++ b/app/artifact-cas/internal/conf/conf.proto @@ -25,10 +25,6 @@ message Bootstrap { Auth auth = 2; Observability observability = 3; credentials.v1.Credentials credentials_service = 4; - // Deployment-level configuration for managed CAS storage backends - // (provisioned and operated by Chainloop, not by tenants). Optional — - // omitting a sub-block keeps the corresponding provider unregistered. - ManagedCASBackends managed_cas_backends = 5; message Observability { Sentry sentry = 1; @@ -83,23 +79,6 @@ message Server { HTTP http_metrics = 3; } -// ManagedCASBackends mirrors the controlplane's `ManagedCASBackends` -// block. Defined independently here so the artifact-cas binary doesn't -// depend on the controlplane's protobuf package. Keep field numbering -// in sync across both definitions. -message ManagedCASBackends { - S3AccessPoint s3_access_point = 1; - - message S3AccessPoint { - string base_role_arn = 1; - string region = 2; - google.protobuf.Duration session_duration = 3; - // DEV ONLY — see controlplane proto for full doc. Bypasses - // sts:AssumeRole and uses the pod's ambient AWS identity directly. - bool dev_mode_use_ambient_credentials = 4; - } -} - message Auth { // Public key used to verify the received JWT token // This token in the context of chainloop has been crafted by the controlplane diff --git a/app/controlplane/cmd/wire.go b/app/controlplane/cmd/wire.go index 775785e48..5fd11a5ea 100644 --- a/app/controlplane/cmd/wire.go +++ b/app/controlplane/cmd/wire.go @@ -37,7 +37,6 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/policies" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" - "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/cache/attestationbundle" "github.com/chainloop-dev/chainloop/pkg/cache/policyevalbundle" @@ -56,12 +55,11 @@ func wireApp(context.Context, *conf.Bootstrap, credentials.ReaderWriter, log.Log data.ProviderSet, biz.ProviderSet, loader.LoadProviders, - newLoaderOptions, service.ProviderSet, wire.Bind(new(biz.CASClient), new(*biz.CASClientUseCase)), serviceOpts, wire.Value([]biz.CASClientOpts{}), - wire.FieldsOf(new(*conf.Bootstrap), "Server", "Auth", "Data", "CasServer", "ReferrerSharedIndex", "Onboarding", "PrometheusIntegration", "PolicyProviders", "NatsServer", "FederatedAuthentication", "OperationAuthorizationProvider", "ManagedCasBackends"), + wire.FieldsOf(new(*conf.Bootstrap), "Server", "Auth", "Data", "CasServer", "ReferrerSharedIndex", "Onboarding", "PrometheusIntegration", "PolicyProviders", "NatsServer", "FederatedAuthentication", "OperationAuthorizationProvider"), wire.FieldsOf(new(*conf.Data), "Database"), dispatcher.New, authz.NewCasbinEnforcer, @@ -128,26 +126,6 @@ func serviceOpts(l log.Logger, authzUC *biz.AuthzUseCase, pUC *biz.ProjectUseCas } } -// newLoaderOptions builds the loader.Options struct from the deployment -// Bootstrap. When `managed_cas_backends.s3_access_point` is absent (the -// common case for on-prem) S3AccessPoint stays nil and the provider is -// not registered, leaving the binary's behaviour identical to the -// pre-managed-CAS world. -func newLoaderOptions(in *conf.ManagedCASBackends, l log.Logger) *loader.Options { - opts := &loader.Options{Logger: l} - if in == nil || in.GetS3AccessPoint() == nil { - return opts - } - ap := in.GetS3AccessPoint() - opts.S3AccessPoint = &s3accesspoint.Config{ - BaseRoleARN: ap.GetBaseRoleArn(), - Region: ap.GetRegion(), - SessionDuration: ap.GetSessionDuration().AsDuration(), - DevModeUseAmbientCredentials: ap.GetDevModeUseAmbientCredentials(), - } - return opts -} - func newCASServerOptions(in *conf.Bootstrap_CASServer) *biz.CASServerDefaultOpts { if in == nil { return &biz.CASServerDefaultOpts{} diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 4acf5afdf..dfa28f207 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -21,7 +21,6 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/policies" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" - "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/cache/attestationbundle" "github.com/chainloop-dev/chainloop/pkg/cache/policyevalbundle" @@ -65,9 +64,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr membershipRepo := data.NewMembershipRepo(dataData, groupRepo, logger) organizationRepo := data.NewOrganizationRepo(dataData, logger) casBackendRepo := data.NewCASBackendRepo(dataData, logger) - managedCASBackends := bootstrap.ManagedCasBackends - options := newLoaderOptions(managedCASBackends, logger) - providers := loader.LoadProviders(readerWriter, options) + providers := loader.LoadProviders(readerWriter) bootstrap_CASServer := bootstrap.CasServer casServerDefaultOpts := newCASServerOptions(bootstrap_CASServer) bootstrap_NatsServer := bootstrap.NatsServer @@ -468,26 +465,6 @@ func serviceOpts(l log.Logger, authzUC *biz.AuthzUseCase, pUC *biz.ProjectUseCas return []service.NewOpt{service.WithLogger(l), service.WithEnforcer(authzUC), service.WithProjectUseCase(pUC), service.WithGroupUseCase(gUC)} } -// newLoaderOptions builds the loader.Options struct from the deployment -// Bootstrap. When `managed_cas_backends.s3_access_point` is absent (the -// common case for on-prem) S3AccessPoint stays nil and the provider is -// not registered, leaving the binary's behaviour identical to the -// pre-managed-CAS world. -func newLoaderOptions(in *conf.ManagedCASBackends, l log.Logger) *loader.Options { - opts := &loader.Options{Logger: l} - if in == nil || in.GetS3AccessPoint() == nil { - return opts - } - ap := in.GetS3AccessPoint() - opts.S3AccessPoint = &s3accesspoint.Config{ - BaseRoleARN: ap.GetBaseRoleArn(), - Region: ap.GetRegion(), - SessionDuration: ap.GetSessionDuration().AsDuration(), - DevModeUseAmbientCredentials: ap.GetDevModeUseAmbientCredentials(), - } - return opts -} - func newCASServerOptions(in *conf.Bootstrap_CASServer) *biz.CASServerDefaultOpts { if in == nil { return &biz.CASServerDefaultOpts{} diff --git a/app/controlplane/configs/config.devel.yaml b/app/controlplane/configs/config.devel.yaml index 593a4f6b7..07976cfca 100644 --- a/app/controlplane/configs/config.devel.yaml +++ b/app/controlplane/configs/config.devel.yaml @@ -123,19 +123,3 @@ ui_dashboard_url: http://localhost:3000 attestations: skip_db_storage: true - -# Optional managed CAS provider (S3 Access Points). When set, the -# controlplane registers AWS-S3-ACCESS-POINT alongside the always-on -# providers (OCI, S3, AzureBlob). Leave commented out for on-prem -# deployments that don't use managed CAS — the pod's ambient AWS -# identity (IRSA / instance profile / AWS_* env vars) is what calls -# sts:AssumeRole on base_role_arn; no static credentials live here. -# managed_cas_backends: -# s3_access_point: -# base_role_arn: arn:aws:iam::123456789012:role/chainloop-cas-tenant -# region: us-east-1 -# session_duration: 1h -# # DEV ONLY: bypass sts:AssumeRole and use whatever AWS identity the -# # SDK default credential chain produces (env vars, ~/.aws/credentials, -# # IRSA, …). Skips per-tenant isolation; never enable in production. -# # dev_mode_use_ambient_credentials: true diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go index 4308b4b0e..038059240 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go @@ -83,14 +83,9 @@ type Bootstrap struct { // Optional external operation authorization provider OperationAuthorizationProvider *OperationAuthorizationProvider `protobuf:"bytes,20,opt,name=operation_authorization_provider,json=operationAuthorizationProvider,proto3" json:"operation_authorization_provider,omitempty"` // Attestation storage and processing options - Attestations *Attestations `protobuf:"bytes,21,opt,name=attestations,proto3" json:"attestations,omitempty"` - // Deployment-level configuration for managed CAS storage backends - // (provisioned and operated by Chainloop, not by tenants). Optional — - // omitting a sub-block keeps the corresponding provider unregistered, - // so on-prem deployments without managed CAS are unaffected. - ManagedCasBackends *ManagedCASBackends `protobuf:"bytes,22,opt,name=managed_cas_backends,json=managedCasBackends,proto3" json:"managed_cas_backends,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Attestations *Attestations `protobuf:"bytes,21,opt,name=attestations,proto3" json:"attestations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Bootstrap) Reset() { @@ -271,66 +266,6 @@ func (x *Bootstrap) GetAttestations() *Attestations { return nil } -func (x *Bootstrap) GetManagedCasBackends() *ManagedCASBackends { - if x != nil { - return x.ManagedCasBackends - } - return nil -} - -// ManagedCASBackends groups the additive, deployment-level config -// blocks for the storage providers that back Chainloop-managed CAS -// backends. New managed providers append a nested message rather -// than adding top-level fields to Bootstrap, so the surface stays -// organised as more backends are added. -type ManagedCASBackends struct { - state protoimpl.MessageState `protogen:"open.v1"` - // S3 Access Point provider — used by SaaS managed CAS to share one - // physical bucket across tenants. Authentication uses the pod's - // ambient AWS identity (IRSA / instance profile / env vars); no static - // credentials live in this block by design. - S3AccessPoint *ManagedCASBackends_S3AccessPoint `protobuf:"bytes,1,opt,name=s3_access_point,json=s3AccessPoint,proto3" json:"s3_access_point,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ManagedCASBackends) Reset() { - *x = ManagedCASBackends{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ManagedCASBackends) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ManagedCASBackends) ProtoMessage() {} - -func (x *ManagedCASBackends) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ManagedCASBackends.ProtoReflect.Descriptor instead. -func (*ManagedCASBackends) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1} -} - -func (x *ManagedCASBackends) GetS3AccessPoint() *ManagedCASBackends_S3AccessPoint { - if x != nil { - return x.S3AccessPoint - } - return nil -} - type Attestations struct { state protoimpl.MessageState `protogen:"open.v1"` // When true, skip writing the attestation bundle to the per-run row in @@ -346,7 +281,7 @@ type Attestations struct { func (x *Attestations) Reset() { *x = Attestations{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -358,7 +293,7 @@ func (x *Attestations) String() string { func (*Attestations) ProtoMessage() {} func (x *Attestations) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -371,7 +306,7 @@ func (x *Attestations) ProtoReflect() protoreflect.Message { // Deprecated: Use Attestations.ProtoReflect.Descriptor instead. func (*Attestations) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{2} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1} } func (x *Attestations) GetSkipDbStorage() bool { @@ -393,7 +328,7 @@ type OperationAuthorizationProvider struct { func (x *OperationAuthorizationProvider) Reset() { *x = OperationAuthorizationProvider{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -405,7 +340,7 @@ func (x *OperationAuthorizationProvider) String() string { func (*OperationAuthorizationProvider) ProtoMessage() {} func (x *OperationAuthorizationProvider) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -418,7 +353,7 @@ func (x *OperationAuthorizationProvider) ProtoReflect() protoreflect.Message { // Deprecated: Use OperationAuthorizationProvider.ProtoReflect.Descriptor instead. func (*OperationAuthorizationProvider) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{3} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{2} } func (x *OperationAuthorizationProvider) GetUrl() string { @@ -447,7 +382,7 @@ type FederatedAuthentication struct { func (x *FederatedAuthentication) Reset() { *x = FederatedAuthentication{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -459,7 +394,7 @@ func (x *FederatedAuthentication) String() string { func (*FederatedAuthentication) ProtoMessage() {} func (x *FederatedAuthentication) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -472,7 +407,7 @@ func (x *FederatedAuthentication) ProtoReflect() protoreflect.Message { // Deprecated: Use FederatedAuthentication.ProtoReflect.Descriptor instead. func (*FederatedAuthentication) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{4} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{3} } func (x *FederatedAuthentication) GetUrl() string { @@ -506,7 +441,7 @@ type PolicyProvider struct { func (x *PolicyProvider) Reset() { *x = PolicyProvider{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -518,7 +453,7 @@ func (x *PolicyProvider) String() string { func (*PolicyProvider) ProtoMessage() {} func (x *PolicyProvider) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -531,7 +466,7 @@ func (x *PolicyProvider) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyProvider.ProtoReflect.Descriptor instead. func (*PolicyProvider) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{4} } func (x *PolicyProvider) GetName() string { @@ -579,7 +514,7 @@ type ReferrerSharedIndex struct { func (x *ReferrerSharedIndex) Reset() { *x = ReferrerSharedIndex{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -591,7 +526,7 @@ func (x *ReferrerSharedIndex) String() string { func (*ReferrerSharedIndex) ProtoMessage() {} func (x *ReferrerSharedIndex) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -604,7 +539,7 @@ func (x *ReferrerSharedIndex) ProtoReflect() protoreflect.Message { // Deprecated: Use ReferrerSharedIndex.ProtoReflect.Descriptor instead. func (*ReferrerSharedIndex) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5} } func (x *ReferrerSharedIndex) GetEnabled() bool { @@ -633,7 +568,7 @@ type Server struct { func (x *Server) Reset() { *x = Server{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -645,7 +580,7 @@ func (x *Server) String() string { func (*Server) ProtoMessage() {} func (x *Server) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -658,7 +593,7 @@ func (x *Server) ProtoReflect() protoreflect.Message { // Deprecated: Use Server.ProtoReflect.Descriptor instead. func (*Server) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6} } func (x *Server) GetHttp() *Server_HTTP { @@ -691,7 +626,7 @@ type Data struct { func (x *Data) Reset() { *x = Data{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -703,7 +638,7 @@ func (x *Data) String() string { func (*Data) ProtoMessage() {} func (x *Data) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -716,7 +651,7 @@ func (x *Data) ProtoReflect() protoreflect.Message { // Deprecated: Use Data.ProtoReflect.Descriptor instead. func (*Data) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7} } func (x *Data) GetDatabase() *Data_Database { @@ -741,7 +676,7 @@ type Auth struct { func (x *Auth) Reset() { *x = Auth{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -753,7 +688,7 @@ func (x *Auth) String() string { func (*Auth) ProtoMessage() {} func (x *Auth) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -766,7 +701,7 @@ func (x *Auth) ProtoReflect() protoreflect.Message { // Deprecated: Use Auth.ProtoReflect.Descriptor instead. func (*Auth) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8} } func (x *Auth) GetGeneratedJwsHmacSecret() string { @@ -818,7 +753,7 @@ type TSA struct { func (x *TSA) Reset() { *x = TSA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -830,7 +765,7 @@ func (x *TSA) String() string { func (*TSA) ProtoMessage() {} func (x *TSA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -843,7 +778,7 @@ func (x *TSA) ProtoReflect() protoreflect.Message { // Deprecated: Use TSA.ProtoReflect.Descriptor instead. func (*TSA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9} } func (x *TSA) GetUrl() string { @@ -884,7 +819,7 @@ type CA struct { func (x *CA) Reset() { *x = CA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -896,7 +831,7 @@ func (x *CA) String() string { func (*CA) ProtoMessage() {} func (x *CA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -909,7 +844,7 @@ func (x *CA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA.ProtoReflect.Descriptor instead. func (*CA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10} } func (x *CA) GetCa() isCA_Ca { @@ -971,7 +906,7 @@ type PrometheusIntegrationSpec struct { func (x *PrometheusIntegrationSpec) Reset() { *x = PrometheusIntegrationSpec{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -983,7 +918,7 @@ func (x *PrometheusIntegrationSpec) String() string { func (*PrometheusIntegrationSpec) ProtoMessage() {} func (x *PrometheusIntegrationSpec) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -996,7 +931,7 @@ func (x *PrometheusIntegrationSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use PrometheusIntegrationSpec.ProtoReflect.Descriptor instead. func (*PrometheusIntegrationSpec) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{12} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11} } func (x *PrometheusIntegrationSpec) GetOrgName() string { @@ -1016,7 +951,7 @@ type Bootstrap_Observability struct { func (x *Bootstrap_Observability) Reset() { *x = Bootstrap_Observability{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1028,7 +963,7 @@ func (x *Bootstrap_Observability) String() string { func (*Bootstrap_Observability) ProtoMessage() {} func (x *Bootstrap_Observability) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1078,7 +1013,7 @@ type Bootstrap_CASServer struct { func (x *Bootstrap_CASServer) Reset() { *x = Bootstrap_CASServer{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1090,7 +1025,7 @@ func (x *Bootstrap_CASServer) String() string { func (*Bootstrap_CASServer) ProtoMessage() {} func (x *Bootstrap_CASServer) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1151,7 +1086,7 @@ type Bootstrap_NatsServer struct { func (x *Bootstrap_NatsServer) Reset() { *x = Bootstrap_NatsServer{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1163,7 +1098,7 @@ func (x *Bootstrap_NatsServer) String() string { func (*Bootstrap_NatsServer) ProtoMessage() {} func (x *Bootstrap_NatsServer) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1230,7 +1165,7 @@ type Bootstrap_Observability_Sentry struct { func (x *Bootstrap_Observability_Sentry) Reset() { *x = Bootstrap_Observability_Sentry{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1242,7 +1177,7 @@ func (x *Bootstrap_Observability_Sentry) String() string { func (*Bootstrap_Observability_Sentry) ProtoMessage() {} func (x *Bootstrap_Observability_Sentry) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1289,7 +1224,7 @@ type Bootstrap_Observability_Tracing struct { func (x *Bootstrap_Observability_Tracing) Reset() { *x = Bootstrap_Observability_Tracing{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1301,7 +1236,7 @@ func (x *Bootstrap_Observability_Tracing) String() string { func (*Bootstrap_Observability_Tracing) ProtoMessage() {} func (x *Bootstrap_Observability_Tracing) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1345,87 +1280,6 @@ func (x *Bootstrap_Observability_Tracing) GetSamplingRatio() float64 { return 0 } -type ManagedCASBackends_S3AccessPoint struct { - state protoimpl.MessageState `protogen:"open.v1"` - // IAM role the controlplane / artifact-cas pod assumes per request - // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every - // access point in the account. Required in production; may be empty - // when dev_mode_use_ambient_credentials is true (see CEL constraint - // above). - BaseRoleArn string `protobuf:"bytes,1,opt,name=base_role_arn,json=baseRoleArn,proto3" json:"base_role_arn,omitempty"` - // Default AWS region for the underlying bucket and access points. - // Individual managed CASBackend rows can override per-tenant. - Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` - // STS token lifetime. Defaults to 1h when unset. Ignored in dev mode. - SessionDuration *durationpb.Duration `protobuf:"bytes,3,opt,name=session_duration,json=sessionDuration,proto3" json:"session_duration,omitempty"` - // dev_mode_use_ambient_credentials short-circuits sts:AssumeRole and - // routes S3 calls through whatever ambient AWS identity the SDK's - // default credential chain produced (env vars, ~/.aws/credentials, - // instance profile, IRSA, …). DEV ONLY — this bypasses per-tenant - // isolation and MUST NOT be set in multi-tenant deployments. - DevModeUseAmbientCredentials bool `protobuf:"varint,4,opt,name=dev_mode_use_ambient_credentials,json=devModeUseAmbientCredentials,proto3" json:"dev_mode_use_ambient_credentials,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ManagedCASBackends_S3AccessPoint) Reset() { - *x = ManagedCASBackends_S3AccessPoint{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ManagedCASBackends_S3AccessPoint) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ManagedCASBackends_S3AccessPoint) ProtoMessage() {} - -func (x *ManagedCASBackends_S3AccessPoint) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ManagedCASBackends_S3AccessPoint.ProtoReflect.Descriptor instead. -func (*ManagedCASBackends_S3AccessPoint) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1, 0} -} - -func (x *ManagedCASBackends_S3AccessPoint) GetBaseRoleArn() string { - if x != nil { - return x.BaseRoleArn - } - return "" -} - -func (x *ManagedCASBackends_S3AccessPoint) GetRegion() string { - if x != nil { - return x.Region - } - return "" -} - -func (x *ManagedCASBackends_S3AccessPoint) GetSessionDuration() *durationpb.Duration { - if x != nil { - return x.SessionDuration - } - return nil -} - -func (x *ManagedCASBackends_S3AccessPoint) GetDevModeUseAmbientCredentials() bool { - if x != nil { - return x.DevModeUseAmbientCredentials - } - return false -} - type Server_HTTP struct { state protoimpl.MessageState `protogen:"open.v1"` Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` @@ -1440,7 +1294,7 @@ type Server_HTTP struct { func (x *Server_HTTP) Reset() { *x = Server_HTTP{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1452,7 +1306,7 @@ func (x *Server_HTTP) String() string { func (*Server_HTTP) ProtoMessage() {} func (x *Server_HTTP) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1465,7 +1319,7 @@ func (x *Server_HTTP) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_HTTP.ProtoReflect.Descriptor instead. func (*Server_HTTP) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 0} } func (x *Server_HTTP) GetNetwork() string { @@ -1507,7 +1361,7 @@ type Server_TLS struct { func (x *Server_TLS) Reset() { *x = Server_TLS{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1519,7 +1373,7 @@ func (x *Server_TLS) String() string { func (*Server_TLS) ProtoMessage() {} func (x *Server_TLS) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1532,7 +1386,7 @@ func (x *Server_TLS) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_TLS.ProtoReflect.Descriptor instead. func (*Server_TLS) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 1} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 1} } func (x *Server_TLS) GetCertificate() string { @@ -1564,7 +1418,7 @@ type Server_GRPC struct { func (x *Server_GRPC) Reset() { *x = Server_GRPC{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1576,7 +1430,7 @@ func (x *Server_GRPC) String() string { func (*Server_GRPC) ProtoMessage() {} func (x *Server_GRPC) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1589,7 +1443,7 @@ func (x *Server_GRPC) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_GRPC.ProtoReflect.Descriptor instead. func (*Server_GRPC) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 2} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 2} } func (x *Server_GRPC) GetNetwork() string { @@ -1643,7 +1497,7 @@ type Data_Database struct { func (x *Data_Database) Reset() { *x = Data_Database{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1655,7 +1509,7 @@ func (x *Data_Database) String() string { func (*Data_Database) ProtoMessage() {} func (x *Data_Database) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1668,7 +1522,7 @@ func (x *Data_Database) ProtoReflect() protoreflect.Message { // Deprecated: Use Data_Database.ProtoReflect.Descriptor instead. func (*Data_Database) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 0} } func (x *Data_Database) GetDriver() string { @@ -1720,7 +1574,7 @@ type Auth_OIDC struct { func (x *Auth_OIDC) Reset() { *x = Auth_OIDC{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1732,7 +1586,7 @@ func (x *Auth_OIDC) String() string { func (*Auth_OIDC) ProtoMessage() {} func (x *Auth_OIDC) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1745,7 +1599,7 @@ func (x *Auth_OIDC) ProtoReflect() protoreflect.Message { // Deprecated: Use Auth_OIDC.ProtoReflect.Descriptor instead. func (*Auth_OIDC) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8, 0} } func (x *Auth_OIDC) GetDomain() string { @@ -1787,7 +1641,7 @@ type CA_FileCA struct { func (x *CA_FileCA) Reset() { *x = CA_FileCA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[24] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1799,7 +1653,7 @@ func (x *CA_FileCA) String() string { func (*CA_FileCA) ProtoMessage() {} func (x *CA_FileCA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[24] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1812,7 +1666,7 @@ func (x *CA_FileCA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA_FileCA.ProtoReflect.Descriptor instead. func (*CA_FileCA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10, 0} } func (x *CA_FileCA) GetCertPath() string { @@ -1853,7 +1707,7 @@ type CA_EJBCA struct { func (x *CA_EJBCA) Reset() { *x = CA_EJBCA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[25] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1865,7 +1719,7 @@ func (x *CA_EJBCA) String() string { func (*CA_EJBCA) ProtoMessage() {} func (x *CA_EJBCA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[25] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1878,7 +1732,7 @@ func (x *CA_EJBCA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA_EJBCA.ProtoReflect.Descriptor instead. func (*CA_EJBCA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11, 1} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10, 1} } func (x *CA_EJBCA) GetServerUrl() string { @@ -1934,7 +1788,7 @@ var File_controlplane_config_v1_conf_proto protoreflect.FileDescriptor const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\n" + - "!controlplane/config/v1/conf.proto\x12\x16controlplane.config.v1\x1a\x1bbuf/validate/validate.proto\x1a#controlplane/config/v1/config.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xd3\x12\n" + + "!controlplane/config/v1/conf.proto\x12\x16controlplane.config.v1\x1a\x1bbuf/validate/validate.proto\x1a#controlplane/config/v1/config.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xf5\x11\n" + "\tBootstrap\x126\n" + "\x06server\x18\x01 \x01(\v2\x1e.controlplane.config.v1.ServerR\x06server\x120\n" + "\x04data\x18\x02 \x01(\v2\x1c.controlplane.config.v1.DataR\x04data\x120\n" + @@ -1962,8 +1816,7 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x15restrict_org_creation\x18\x12 \x01(\bR\x13restrictOrgCreation\x12(\n" + "\x10ui_dashboard_url\x18\x13 \x01(\tR\x0euiDashboardUrl\x12\x80\x01\n" + " operation_authorization_provider\x18\x14 \x01(\v26.controlplane.config.v1.OperationAuthorizationProviderR\x1eoperationAuthorizationProvider\x12H\n" + - "\fattestations\x18\x15 \x01(\v2$.controlplane.config.v1.AttestationsR\fattestations\x12\\\n" + - "\x14managed_cas_backends\x18\x16 \x01(\v2*.controlplane.config.v1.ManagedCASBackendsR\x12managedCasBackends\x1a\x8d\x03\n" + + "\fattestations\x18\x15 \x01(\v2$.controlplane.config.v1.AttestationsR\fattestations\x1a\x8d\x03\n" + "\rObservability\x12N\n" + "\x06sentry\x18\x01 \x01(\v26.controlplane.config.v1.Bootstrap.Observability.SentryR\x06sentry\x12Q\n" + "\atracing\x18\x02 \x01(\v27.controlplane.config.v1.Bootstrap.Observability.TracingR\atracing\x1a<\n" + @@ -1986,15 +1839,7 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x03uri\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x03uri\x12\x1f\n" + "\x05token\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x05token\x12\x1a\n" + "\breplicas\x18\x03 \x01(\x05R\breplicasB\x10\n" + - "\x0eauthentication\"\xa6\x04\n" + - "\x12ManagedCASBackends\x12`\n" + - "\x0fs3_access_point\x18\x01 \x01(\v28.controlplane.config.v1.ManagedCASBackends.S3AccessPointR\rs3AccessPoint\x1a\xad\x03\n" + - "\rS3AccessPoint\x12\"\n" + - "\rbase_role_arn\x18\x01 \x01(\tR\vbaseRoleArn\x12\x1f\n" + - "\x06region\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06region\x12D\n" + - "\x10session_duration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0fsessionDuration\x12F\n" + - " dev_mode_use_ambient_credentials\x18\x04 \x01(\bR\x1cdevModeUseAmbientCredentials:\xc8\x01\xbaH\xc4\x01\x1a\xc1\x01\n" + - ".s3_access_point.base_role_arn_required_in_prod\x12Hbase_role_arn is required when dev_mode_use_ambient_credentials is false\x1aEthis.dev_mode_use_ambient_credentials || size(this.base_role_arn) > 0\"6\n" + + "\x0eauthentication\"6\n" + "\fAttestations\x12&\n" + "\x0fskip_db_storage\x18\x01 \x01(\bR\rskipDbStorage\"V\n" + "\x1eOperationAuthorizationProvider\x12\x1a\n" + @@ -2090,80 +1935,75 @@ func file_controlplane_config_v1_conf_proto_rawDescGZIP() []byte { return file_controlplane_config_v1_conf_proto_rawDescData } -var file_controlplane_config_v1_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_controlplane_config_v1_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_controlplane_config_v1_conf_proto_goTypes = []any{ - (*Bootstrap)(nil), // 0: controlplane.config.v1.Bootstrap - (*ManagedCASBackends)(nil), // 1: controlplane.config.v1.ManagedCASBackends - (*Attestations)(nil), // 2: controlplane.config.v1.Attestations - (*OperationAuthorizationProvider)(nil), // 3: controlplane.config.v1.OperationAuthorizationProvider - (*FederatedAuthentication)(nil), // 4: controlplane.config.v1.FederatedAuthentication - (*PolicyProvider)(nil), // 5: controlplane.config.v1.PolicyProvider - (*ReferrerSharedIndex)(nil), // 6: controlplane.config.v1.ReferrerSharedIndex - (*Server)(nil), // 7: controlplane.config.v1.Server - (*Data)(nil), // 8: controlplane.config.v1.Data - (*Auth)(nil), // 9: controlplane.config.v1.Auth - (*TSA)(nil), // 10: controlplane.config.v1.TSA - (*CA)(nil), // 11: controlplane.config.v1.CA - (*PrometheusIntegrationSpec)(nil), // 12: controlplane.config.v1.PrometheusIntegrationSpec - (*Bootstrap_Observability)(nil), // 13: controlplane.config.v1.Bootstrap.Observability - (*Bootstrap_CASServer)(nil), // 14: controlplane.config.v1.Bootstrap.CASServer - (*Bootstrap_NatsServer)(nil), // 15: controlplane.config.v1.Bootstrap.NatsServer - (*Bootstrap_Observability_Sentry)(nil), // 16: controlplane.config.v1.Bootstrap.Observability.Sentry - (*Bootstrap_Observability_Tracing)(nil), // 17: controlplane.config.v1.Bootstrap.Observability.Tracing - (*ManagedCASBackends_S3AccessPoint)(nil), // 18: controlplane.config.v1.ManagedCASBackends.S3AccessPoint - (*Server_HTTP)(nil), // 19: controlplane.config.v1.Server.HTTP - (*Server_TLS)(nil), // 20: controlplane.config.v1.Server.TLS - (*Server_GRPC)(nil), // 21: controlplane.config.v1.Server.GRPC - (*Data_Database)(nil), // 22: controlplane.config.v1.Data.Database - (*Auth_OIDC)(nil), // 23: controlplane.config.v1.Auth.OIDC - (*CA_FileCA)(nil), // 24: controlplane.config.v1.CA.FileCA - (*CA_EJBCA)(nil), // 25: controlplane.config.v1.CA.EJBCA - (*v1.Credentials)(nil), // 26: credentials.v1.Credentials - (*v11.OnboardingSpec)(nil), // 27: controlplane.config.v1.OnboardingSpec - (*v11.AllowList)(nil), // 28: controlplane.config.v1.AllowList - (*durationpb.Duration)(nil), // 29: google.protobuf.Duration + (*Bootstrap)(nil), // 0: controlplane.config.v1.Bootstrap + (*Attestations)(nil), // 1: controlplane.config.v1.Attestations + (*OperationAuthorizationProvider)(nil), // 2: controlplane.config.v1.OperationAuthorizationProvider + (*FederatedAuthentication)(nil), // 3: controlplane.config.v1.FederatedAuthentication + (*PolicyProvider)(nil), // 4: controlplane.config.v1.PolicyProvider + (*ReferrerSharedIndex)(nil), // 5: controlplane.config.v1.ReferrerSharedIndex + (*Server)(nil), // 6: controlplane.config.v1.Server + (*Data)(nil), // 7: controlplane.config.v1.Data + (*Auth)(nil), // 8: controlplane.config.v1.Auth + (*TSA)(nil), // 9: controlplane.config.v1.TSA + (*CA)(nil), // 10: controlplane.config.v1.CA + (*PrometheusIntegrationSpec)(nil), // 11: controlplane.config.v1.PrometheusIntegrationSpec + (*Bootstrap_Observability)(nil), // 12: controlplane.config.v1.Bootstrap.Observability + (*Bootstrap_CASServer)(nil), // 13: controlplane.config.v1.Bootstrap.CASServer + (*Bootstrap_NatsServer)(nil), // 14: controlplane.config.v1.Bootstrap.NatsServer + (*Bootstrap_Observability_Sentry)(nil), // 15: controlplane.config.v1.Bootstrap.Observability.Sentry + (*Bootstrap_Observability_Tracing)(nil), // 16: controlplane.config.v1.Bootstrap.Observability.Tracing + (*Server_HTTP)(nil), // 17: controlplane.config.v1.Server.HTTP + (*Server_TLS)(nil), // 18: controlplane.config.v1.Server.TLS + (*Server_GRPC)(nil), // 19: controlplane.config.v1.Server.GRPC + (*Data_Database)(nil), // 20: controlplane.config.v1.Data.Database + (*Auth_OIDC)(nil), // 21: controlplane.config.v1.Auth.OIDC + (*CA_FileCA)(nil), // 22: controlplane.config.v1.CA.FileCA + (*CA_EJBCA)(nil), // 23: controlplane.config.v1.CA.EJBCA + (*v1.Credentials)(nil), // 24: credentials.v1.Credentials + (*v11.OnboardingSpec)(nil), // 25: controlplane.config.v1.OnboardingSpec + (*v11.AllowList)(nil), // 26: controlplane.config.v1.AllowList + (*durationpb.Duration)(nil), // 27: google.protobuf.Duration } var file_controlplane_config_v1_conf_proto_depIdxs = []int32{ - 7, // 0: controlplane.config.v1.Bootstrap.server:type_name -> controlplane.config.v1.Server - 8, // 1: controlplane.config.v1.Bootstrap.data:type_name -> controlplane.config.v1.Data - 9, // 2: controlplane.config.v1.Bootstrap.auth:type_name -> controlplane.config.v1.Auth - 13, // 3: controlplane.config.v1.Bootstrap.observability:type_name -> controlplane.config.v1.Bootstrap.Observability - 26, // 4: controlplane.config.v1.Bootstrap.credentials_service:type_name -> credentials.v1.Credentials - 14, // 5: controlplane.config.v1.Bootstrap.cas_server:type_name -> controlplane.config.v1.Bootstrap.CASServer - 6, // 6: controlplane.config.v1.Bootstrap.referrer_shared_index:type_name -> controlplane.config.v1.ReferrerSharedIndex - 11, // 7: controlplane.config.v1.Bootstrap.certificate_authority:type_name -> controlplane.config.v1.CA - 11, // 8: controlplane.config.v1.Bootstrap.certificate_authorities:type_name -> controlplane.config.v1.CA - 10, // 9: controlplane.config.v1.Bootstrap.timestamp_authorities:type_name -> controlplane.config.v1.TSA - 27, // 10: controlplane.config.v1.Bootstrap.onboarding:type_name -> controlplane.config.v1.OnboardingSpec - 12, // 11: controlplane.config.v1.Bootstrap.prometheus_integration:type_name -> controlplane.config.v1.PrometheusIntegrationSpec - 5, // 12: controlplane.config.v1.Bootstrap.policy_providers:type_name -> controlplane.config.v1.PolicyProvider - 15, // 13: controlplane.config.v1.Bootstrap.nats_server:type_name -> controlplane.config.v1.Bootstrap.NatsServer - 4, // 14: controlplane.config.v1.Bootstrap.federated_authentication:type_name -> controlplane.config.v1.FederatedAuthentication - 3, // 15: controlplane.config.v1.Bootstrap.operation_authorization_provider:type_name -> controlplane.config.v1.OperationAuthorizationProvider - 2, // 16: controlplane.config.v1.Bootstrap.attestations:type_name -> controlplane.config.v1.Attestations - 1, // 17: controlplane.config.v1.Bootstrap.managed_cas_backends:type_name -> controlplane.config.v1.ManagedCASBackends - 18, // 18: controlplane.config.v1.ManagedCASBackends.s3_access_point:type_name -> controlplane.config.v1.ManagedCASBackends.S3AccessPoint - 19, // 19: controlplane.config.v1.Server.http:type_name -> controlplane.config.v1.Server.HTTP - 21, // 20: controlplane.config.v1.Server.grpc:type_name -> controlplane.config.v1.Server.GRPC - 19, // 21: controlplane.config.v1.Server.http_metrics:type_name -> controlplane.config.v1.Server.HTTP - 22, // 22: controlplane.config.v1.Data.database:type_name -> controlplane.config.v1.Data.Database - 28, // 23: controlplane.config.v1.Auth.allow_list:type_name -> controlplane.config.v1.AllowList - 23, // 24: controlplane.config.v1.Auth.oidc:type_name -> controlplane.config.v1.Auth.OIDC - 24, // 25: controlplane.config.v1.CA.file_ca:type_name -> controlplane.config.v1.CA.FileCA - 25, // 26: controlplane.config.v1.CA.ejbca_ca:type_name -> controlplane.config.v1.CA.EJBCA - 16, // 27: controlplane.config.v1.Bootstrap.Observability.sentry:type_name -> controlplane.config.v1.Bootstrap.Observability.Sentry - 17, // 28: controlplane.config.v1.Bootstrap.Observability.tracing:type_name -> controlplane.config.v1.Bootstrap.Observability.Tracing - 21, // 29: controlplane.config.v1.Bootstrap.CASServer.grpc:type_name -> controlplane.config.v1.Server.GRPC - 29, // 30: controlplane.config.v1.ManagedCASBackends.S3AccessPoint.session_duration:type_name -> google.protobuf.Duration - 29, // 31: controlplane.config.v1.Server.HTTP.timeout:type_name -> google.protobuf.Duration - 29, // 32: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration - 20, // 33: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS - 29, // 34: controlplane.config.v1.Data.Database.max_conn_idle_time:type_name -> google.protobuf.Duration - 35, // [35:35] is the sub-list for method output_type - 35, // [35:35] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 6, // 0: controlplane.config.v1.Bootstrap.server:type_name -> controlplane.config.v1.Server + 7, // 1: controlplane.config.v1.Bootstrap.data:type_name -> controlplane.config.v1.Data + 8, // 2: controlplane.config.v1.Bootstrap.auth:type_name -> controlplane.config.v1.Auth + 12, // 3: controlplane.config.v1.Bootstrap.observability:type_name -> controlplane.config.v1.Bootstrap.Observability + 24, // 4: controlplane.config.v1.Bootstrap.credentials_service:type_name -> credentials.v1.Credentials + 13, // 5: controlplane.config.v1.Bootstrap.cas_server:type_name -> controlplane.config.v1.Bootstrap.CASServer + 5, // 6: controlplane.config.v1.Bootstrap.referrer_shared_index:type_name -> controlplane.config.v1.ReferrerSharedIndex + 10, // 7: controlplane.config.v1.Bootstrap.certificate_authority:type_name -> controlplane.config.v1.CA + 10, // 8: controlplane.config.v1.Bootstrap.certificate_authorities:type_name -> controlplane.config.v1.CA + 9, // 9: controlplane.config.v1.Bootstrap.timestamp_authorities:type_name -> controlplane.config.v1.TSA + 25, // 10: controlplane.config.v1.Bootstrap.onboarding:type_name -> controlplane.config.v1.OnboardingSpec + 11, // 11: controlplane.config.v1.Bootstrap.prometheus_integration:type_name -> controlplane.config.v1.PrometheusIntegrationSpec + 4, // 12: controlplane.config.v1.Bootstrap.policy_providers:type_name -> controlplane.config.v1.PolicyProvider + 14, // 13: controlplane.config.v1.Bootstrap.nats_server:type_name -> controlplane.config.v1.Bootstrap.NatsServer + 3, // 14: controlplane.config.v1.Bootstrap.federated_authentication:type_name -> controlplane.config.v1.FederatedAuthentication + 2, // 15: controlplane.config.v1.Bootstrap.operation_authorization_provider:type_name -> controlplane.config.v1.OperationAuthorizationProvider + 1, // 16: controlplane.config.v1.Bootstrap.attestations:type_name -> controlplane.config.v1.Attestations + 17, // 17: controlplane.config.v1.Server.http:type_name -> controlplane.config.v1.Server.HTTP + 19, // 18: controlplane.config.v1.Server.grpc:type_name -> controlplane.config.v1.Server.GRPC + 17, // 19: controlplane.config.v1.Server.http_metrics:type_name -> controlplane.config.v1.Server.HTTP + 20, // 20: controlplane.config.v1.Data.database:type_name -> controlplane.config.v1.Data.Database + 26, // 21: controlplane.config.v1.Auth.allow_list:type_name -> controlplane.config.v1.AllowList + 21, // 22: controlplane.config.v1.Auth.oidc:type_name -> controlplane.config.v1.Auth.OIDC + 22, // 23: controlplane.config.v1.CA.file_ca:type_name -> controlplane.config.v1.CA.FileCA + 23, // 24: controlplane.config.v1.CA.ejbca_ca:type_name -> controlplane.config.v1.CA.EJBCA + 15, // 25: controlplane.config.v1.Bootstrap.Observability.sentry:type_name -> controlplane.config.v1.Bootstrap.Observability.Sentry + 16, // 26: controlplane.config.v1.Bootstrap.Observability.tracing:type_name -> controlplane.config.v1.Bootstrap.Observability.Tracing + 19, // 27: controlplane.config.v1.Bootstrap.CASServer.grpc:type_name -> controlplane.config.v1.Server.GRPC + 27, // 28: controlplane.config.v1.Server.HTTP.timeout:type_name -> google.protobuf.Duration + 27, // 29: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration + 18, // 30: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS + 27, // 31: controlplane.config.v1.Data.Database.max_conn_idle_time:type_name -> google.protobuf.Duration + 32, // [32:32] is the sub-list for method output_type + 32, // [32:32] is the sub-list for method input_type + 32, // [32:32] is the sub-list for extension type_name + 32, // [32:32] is the sub-list for extension extendee + 0, // [0:32] is the sub-list for field type_name } func init() { file_controlplane_config_v1_conf_proto_init() } @@ -2171,21 +2011,21 @@ func file_controlplane_config_v1_conf_proto_init() { if File_controlplane_config_v1_conf_proto != nil { return } - file_controlplane_config_v1_conf_proto_msgTypes[11].OneofWrappers = []any{ + file_controlplane_config_v1_conf_proto_msgTypes[10].OneofWrappers = []any{ (*CA_FileCa)(nil), (*CA_EjbcaCa)(nil), } - file_controlplane_config_v1_conf_proto_msgTypes[15].OneofWrappers = []any{ + file_controlplane_config_v1_conf_proto_msgTypes[14].OneofWrappers = []any{ (*Bootstrap_NatsServer_Token)(nil), } - file_controlplane_config_v1_conf_proto_msgTypes[17].OneofWrappers = []any{} + file_controlplane_config_v1_conf_proto_msgTypes[16].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_config_v1_conf_proto_rawDesc), len(file_controlplane_config_v1_conf_proto_rawDesc)), NumEnums: 0, - NumMessages: 26, + NumMessages: 24, NumExtensions: 0, NumServices: 0, }, diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto index 87a672943..4b5f7a4ba 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto @@ -126,56 +126,6 @@ message Bootstrap { // Attestation storage and processing options Attestations attestations = 21; - - // Deployment-level configuration for managed CAS storage backends - // (provisioned and operated by Chainloop, not by tenants). Optional — - // omitting a sub-block keeps the corresponding provider unregistered, - // so on-prem deployments without managed CAS are unaffected. - ManagedCASBackends managed_cas_backends = 22; -} - -// ManagedCASBackends groups the additive, deployment-level config -// blocks for the storage providers that back Chainloop-managed CAS -// backends. New managed providers append a nested message rather -// than adding top-level fields to Bootstrap, so the surface stays -// organised as more backends are added. -message ManagedCASBackends { - // S3 Access Point provider — used by SaaS managed CAS to share one - // physical bucket across tenants. Authentication uses the pod's - // ambient AWS identity (IRSA / instance profile / env vars); no static - // credentials live in this block by design. - S3AccessPoint s3_access_point = 1; - - message S3AccessPoint { - // base_role_arn is conditionally required: production deployments - // (dev_mode_use_ambient_credentials=false) must specify a role to - // assume; dev-mode deployments may leave it empty. Enforced at - // config-load time rather than only at first upload so bad config - // surfaces immediately. - option (buf.validate.message).cel = { - id: "s3_access_point.base_role_arn_required_in_prod" - message: "base_role_arn is required when dev_mode_use_ambient_credentials is false" - expression: "this.dev_mode_use_ambient_credentials || size(this.base_role_arn) > 0" - }; - - // IAM role the controlplane / artifact-cas pod assumes per request - // via sts:AssumeRole. Must allow s3:{Get,Put,Delete}Object on every - // access point in the account. Required in production; may be empty - // when dev_mode_use_ambient_credentials is true (see CEL constraint - // above). - string base_role_arn = 1; - // Default AWS region for the underlying bucket and access points. - // Individual managed CASBackend rows can override per-tenant. - string region = 2 [(buf.validate.field).string.min_len = 1]; - // STS token lifetime. Defaults to 1h when unset. Ignored in dev mode. - google.protobuf.Duration session_duration = 3; - // dev_mode_use_ambient_credentials short-circuits sts:AssumeRole and - // routes S3 calls through whatever ambient AWS identity the SDK's - // default credential chain produced (env vars, ~/.aws/credentials, - // instance profile, IRSA, …). DEV ONLY — this bypasses per-tenant - // isolation and MUST NOT be set in multi-tenant deployments. - bool dev_mode_use_ambient_credentials = 4; - } } message Attestations { diff --git a/pkg/blobmanager/loader/loader.go b/pkg/blobmanager/loader/loader.go index cd3ed86b5..19a5fcc82 100644 --- a/pkg/blobmanager/loader/loader.go +++ b/pkg/blobmanager/loader/loader.go @@ -16,8 +16,6 @@ package loader import ( - "github.com/go-kratos/kratos/v2/log" - backends "github.com/chainloop-dev/chainloop/pkg/blobmanager" "github.com/chainloop-dev/chainloop/pkg/blobmanager/azureblob" "github.com/chainloop-dev/chainloop/pkg/blobmanager/oci" @@ -26,55 +24,27 @@ import ( "github.com/chainloop-dev/chainloop/pkg/credentials" ) -// Options gathers the optional, deployment-level config blocks that some -// providers need at startup. New providers should add a nilable field -// here, keeping the zero value equivalent to "don't register this -// provider". Passed by pointer so wire can supply it as a normal value. -type Options struct { - // S3AccessPoint enables the AWS-S3-ACCESS-POINT provider. Nil = off. - S3AccessPoint *s3accesspoint.Config - // Logger is used to surface non-fatal provider-init warnings. - // Optional; loader logs to the default kratos logger when nil. - Logger log.Logger -} - // LoadProviders builds the registry of CAS backend providers consumed by -// both the controlplane and the artifact-cas binaries. The three always-on -// providers (oci, azureblob, s3) are registered unconditionally; the -// access-point provider is only registered when Options.S3AccessPoint is -// non-nil and validates. -// -// A failure to construct a conditional provider logs a warning and is -// otherwise ignored — this keeps a misconfigured s3accesspoint block from -// preventing the binary from starting at all. -// -// Passing a nil Options is valid and equivalent to "register only the -// unconditional providers", so existing test setups don't need to change. -func LoadProviders(creader credentials.Reader, opts *Options) backends.Providers { - if opts == nil { - opts = &Options{} - } - +// both the controlplane and the artifact-cas binaries. All providers are +// registered unconditionally — the s3accesspoint provider has no +// deployment-level config of its own (everything per-tenant lives in the +// secret blob), so on-prem deployments without managed CAS simply never +// have managed rows and the provider is dormant. +func LoadProviders(creader credentials.Reader) backends.Providers { ociProvider := oci.NewBackendProvider(creader) azureBlobProvider := azureblob.NewBackendProvider(creader) s3Provider := s3.NewBackendProvider(creader) + apProvider, err := s3accesspoint.NewBackendProvider(creader) + if err != nil { + // Only fails on a nil credentials reader, which is a programmer + // error caught at startup wiring just like the other providers. + panic(err) + } - providers := backends.Providers{ + return backends.Providers{ ociProvider.ID(): ociProvider, azureBlobProvider.ID(): azureBlobProvider, s3Provider.ID(): s3Provider, + apProvider.ID(): apProvider, } - - if opts.S3AccessPoint != nil { - apProvider, err := s3accesspoint.NewBackendProvider(opts.S3AccessPoint, creader) - if err != nil { - if opts.Logger != nil { - log.NewHelper(opts.Logger).Warnf("s3accesspoint provider not registered: %v", err) - } - } else { - providers[apProvider.ID()] = apProvider - } - } - - return providers } diff --git a/pkg/blobmanager/loader/loader_test.go b/pkg/blobmanager/loader/loader_test.go index 5dbbe9eeb..0980585bf 100644 --- a/pkg/blobmanager/loader/loader_test.go +++ b/pkg/blobmanager/loader/loader_test.go @@ -27,44 +27,16 @@ import ( "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" ) -// stubReader satisfies credentials.Reader; never invoked by the registry -// builder. type stubReader struct{} func (stubReader) ReadCredentials(_ context.Context, _ string, _ any) error { return nil } -func TestLoadProviders_UnconditionalProvidersAlwaysRegistered(t *testing.T) { +func TestLoadProviders_AllRegistered(t *testing.T) { t.Parallel() - for _, opts := range []*Options{nil, {}, {S3AccessPoint: nil}} { - ps := LoadProviders(stubReader{}, opts) - assert.Contains(t, ps, oci.ProviderID) - assert.Contains(t, ps, azureblob.ProviderID) - assert.Contains(t, ps, s3.ProviderID) - assert.NotContains(t, ps, s3accesspoint.ProviderID, - "s3accesspoint must stay off unless explicitly enabled") - } -} - -func TestLoadProviders_RegistersS3AccessPointWhenConfigured(t *testing.T) { - t.Parallel() - - ps := LoadProviders(stubReader{}, &Options{S3AccessPoint: &s3accesspoint.Config{ - BaseRoleARN: "arn:aws:iam::123456789012:role/chainloop-cas-tenant", - Region: "us-east-1", - }}) - assert.Contains(t, ps, s3accesspoint.ProviderID) -} - -func TestLoadProviders_SkipsS3AccessPointOnBadConfig(t *testing.T) { - t.Parallel() - - // Missing required field — provider construction returns an error, - // loader logs a warning and continues without the provider rather - // than panicking. The remaining three providers must still be there. - ps := LoadProviders(stubReader{}, &Options{S3AccessPoint: &s3accesspoint.Config{ - Region: "us-east-1", - }}) - assert.NotContains(t, ps, s3accesspoint.ProviderID) + ps := LoadProviders(stubReader{}) + assert.Contains(t, ps, oci.ProviderID) + assert.Contains(t, ps, azureblob.ProviderID) assert.Contains(t, ps, s3.ProviderID) + assert.Contains(t, ps, s3accesspoint.ProviderID) } diff --git a/pkg/blobmanager/s3accesspoint/backend.go b/pkg/blobmanager/s3accesspoint/backend.go index e823fe88a..e9d639c5e 100644 --- a/pkg/blobmanager/s3accesspoint/backend.go +++ b/pkg/blobmanager/s3accesspoint/backend.go @@ -23,7 +23,6 @@ import ( "fmt" "io" "strings" - "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -52,7 +51,6 @@ var ErrMissingRequestingOrg = errors.New("s3accesspoint: requesting org missing // bound to one access point; the actual AWS credentials are minted // per-request via STS using the org UUID found in the request context. type Backend struct { - cfg *Config creds *Credentials // stsClient is built once at construction using the pod's ambient @@ -72,32 +70,24 @@ var _ backend.UploaderDownloader = (*Backend)(nil) // NewBackend constructs a *Backend wired to an STS-backed credentials // provider. ctx is used only for the initial AWS config load (DNS lookups, // IMDS, IRSA token reads); it is not retained for later operations. -func NewBackend(ctx context.Context, cfg *Config, creds *Credentials) (*Backend, error) { - if err := cfg.Validate(); err != nil { - return nil, err - } +func NewBackend(ctx context.Context, creds *Credentials) (*Backend, error) { if err := creds.Validate(); err != nil { return nil, err } - region := cfg.Region - if creds.Region != "" { - region = creds.Region - } - // Load the pod's ambient AWS identity once. Subsequent SDK calls // reuse the resulting config; no per-request credential lookup // against the pod identity is necessary. - awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(creds.Region)) if err != nil { return nil, fmt.Errorf("loading aws config: %w", err) } stsClient := sts.NewFromConfig(awsCfg) - // The per-request credential provider closes over cfg + creds so it - // can build the session policy from the AP ARN and key prefix every - // time AWS asks for fresh credentials. NewCredentialsCache handles + // The per-request credential provider closes over creds so it can + // build the session policy from the AP ARN and key prefix every time + // AWS asks for fresh credentials. NewCredentialsCache handles // proactive refresh and concurrent-call deduplication. // // In dev mode we hand the provider the ambient credentials so it can @@ -106,9 +96,7 @@ func NewBackend(ctx context.Context, cfg *Config, creds *Credentials) (*Backend, credProvider := aws.NewCredentialsCache(&sessionCredentialsProvider{ stsClient: stsClient, ambientCreds: awsCfg.Credentials, - baseRoleARN: cfg.BaseRoleARN, - sessionDuration: cfg.SessionDuration, - useAmbientForRetrieve: cfg.DevModeUseAmbientCredentials, + useAmbientForRetrieve: devModeEnabled(), creds: creds, }) @@ -117,7 +105,6 @@ func NewBackend(ctx context.Context, cfg *Config, creds *Credentials) (*Backend, }) return &Backend{ - cfg: cfg, creds: creds, stsClient: stsClient, s3Client: s3Client, @@ -275,9 +262,7 @@ func (b *Backend) CheckWritePermissions(ctx context.Context) error { // reusing the temporary credentials across consecutive calls until the // expiration window approaches. type sessionCredentialsProvider struct { - stsClient *sts.Client - baseRoleARN string - sessionDuration time.Duration + stsClient *sts.Client // ambientCreds is the SDK-default credentials provider captured from // awsCfg at construction time. Only consulted when @@ -285,7 +270,7 @@ type sessionCredentialsProvider struct { ambientCreds aws.CredentialsProvider // useAmbientForRetrieve short-circuits Retrieve to return the pod's // ambient AWS credentials directly without calling sts:AssumeRole. - // DEV ONLY — see Config.DevModeUseAmbientCredentials. + // DEV ONLY — see DevModeEnvVar. useAmbientForRetrieve bool creds *Credentials @@ -322,16 +307,11 @@ func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credenti // scope to escape into another tenant's namespace. sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN, info.OrgID) - durSecs := int32(p.sessionDuration / time.Second) - if durSecs <= 0 { - durSecs = int32(DefaultSessionDuration / time.Second) - } - out, err := p.stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ - RoleArn: aws.String(p.baseRoleARN), + RoleArn: aws.String(p.creds.BaseRoleARN), RoleSessionName: aws.String(roleSessionName(info.OrgID)), Policy: aws.String(sessionPolicy), - DurationSeconds: aws.Int32(durSecs), + DurationSeconds: aws.Int32(int32(SessionDuration.Seconds())), }) if err != nil { return aws.Credentials{}, fmt.Errorf("sts:AssumeRole for org %s: %w", info.OrgID, err) diff --git a/pkg/blobmanager/s3accesspoint/backend_test.go b/pkg/blobmanager/s3accesspoint/backend_test.go index bf0f577c6..6b22befaf 100644 --- a/pkg/blobmanager/s3accesspoint/backend_test.go +++ b/pkg/blobmanager/s3accesspoint/backend_test.go @@ -188,6 +188,8 @@ func newTestBackend(t *testing.T) *Backend { t.Helper() return backendForCreds(t, &Credentials{ AccessPointARN: "arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org-abc", + Region: "us-east-1", + BaseRoleARN: "arn:aws:iam::123456789012:role/chainloop-cas-tenant", }) } @@ -201,11 +203,7 @@ func backendForCreds(t *testing.T, creds *Credentials) *Backend { // up — defensive in case the env-var pickup order changes. t.Setenv("AWS_EC2_METADATA_DISABLED", "true") - b, err := NewBackend(context.Background(), &Config{ - BaseRoleARN: "arn:aws:iam::123456789012:role/chainloop-cas-tenant", - Region: "us-east-1", - SessionDuration: DefaultSessionDuration, - }, creds) + b, err := NewBackend(context.Background(), creds) require.NoError(t, err) return b } diff --git a/pkg/blobmanager/s3accesspoint/provider.go b/pkg/blobmanager/s3accesspoint/provider.go index d5a41d4f9..69a55cd25 100644 --- a/pkg/blobmanager/s3accesspoint/provider.go +++ b/pkg/blobmanager/s3accesspoint/provider.go @@ -43,6 +43,7 @@ import ( "errors" "fmt" "log" + "os" "strings" "time" @@ -55,69 +56,35 @@ import ( // the regular s3 one. const ProviderID = "AWS-S3-ACCESS-POINT" -// DefaultSessionDuration is the STS token lifetime used when the deployment -// config doesn't specify one. STS allows up to 12h; 1h keeps blast radius -// of a leaked token small while still giving the credential cache useful -// reuse across consecutive uploads. -const DefaultSessionDuration = time.Hour - -// Config carries the deployment-wide settings the provider needs to mint -// scoped per-tenant credentials. It does NOT contain AWS access keys — the -// pod's ambient IAM identity (IRSA / Pod Identity / instance profile / -// AWS_* env vars) is used to call sts:AssumeRole on BaseRoleARN. -type Config struct { - // BaseRoleARN is the IAM role the controlplane / artifact-cas pod - // assumes via STS at each upload/download. Its permission policy must - // allow s3:{Get,Put,Delete,Head}Object against every access point in - // the account; the per-call session policy narrows that down to one - // AP + one prefix. - // - // Required in production. Ignored (and may be empty) when - // DevModeUseAmbientCredentials is true. - BaseRoleARN string - // Region is the default region for the underlying bucket and the - // access points. Individual managed rows may override this via - // Credentials.Region. - Region string - // SessionDuration is the STS token lifetime. Defaults to - // DefaultSessionDuration when zero. Ignored when - // DevModeUseAmbientCredentials is true. - SessionDuration time.Duration - - // DevModeUseAmbientCredentials short-circuits sts:AssumeRole and - // routes S3 calls through whatever ambient AWS identity the SDK's - // default credential chain produced (env vars, ~/.aws/credentials, - // instance profile, IRSA, …). The fail-closed check on a missing - // requesting-org context is still enforced so callers that forget - // WithRequestingOrg get the same error locally as they would in - // production. - // - // DEV ONLY. This bypasses the per-tenant isolation guarantees that - // the AssumeRole + session-policy + AP-policy chain provides; objects - // addressed via this backend are limited only by whatever the - // developer's IAM identity allows. NEVER set this in a multi-tenant - // deployment. - DevModeUseAmbientCredentials bool -} - -func (c *Config) Validate() error { - if c == nil { - return errors.New("s3accesspoint: nil config") - } - // Base role is only required when we actually plan to assume it. - // In dev mode the SDK's default credential chain stands in for it. - if !c.DevModeUseAmbientCredentials { - if c.BaseRoleARN == "" { - return errors.New("s3accesspoint: base_role_arn is required (or set dev_mode_use_ambient_credentials in dev)") - } - if !strings.HasPrefix(c.BaseRoleARN, "arn:aws:iam::") { - return fmt.Errorf("s3accesspoint: base_role_arn %q is not a valid IAM role ARN", c.BaseRoleARN) - } - } - if c.Region == "" { - return errors.New("s3accesspoint: region is required") +// SessionDuration is the STS token lifetime. STS allows up to 12h; 1h keeps +// blast radius of a leaked token small while still giving the credential +// cache useful reuse across consecutive uploads. +const SessionDuration = time.Hour + +// DevModeEnvVar when set to a truthy value, short-circuits sts:AssumeRole +// and routes S3 calls through whatever ambient AWS identity the SDK's +// default credential chain produced (env vars, ~/.aws/credentials, instance +// profile, IRSA, …). The fail-closed check on a missing requesting-org +// context is still enforced so callers that forget WithRequestingOrg get +// the same error locally as they would in production. +// +// DEV ONLY. This bypasses the per-tenant isolation guarantees that the +// AssumeRole + session-policy + AP-policy chain provides; objects +// addressed via this backend are limited only by whatever the developer's +// IAM identity allows. NEVER set this in a multi-tenant deployment. +const DevModeEnvVar = "CHAINLOOP_S3_ACCESS_POINT_DEV_MODE" + +// devModeEnabled reads DevModeEnvVar and returns true for the usual truthy +// spellings. Kept as a package-level function so tests can swap the env +// var with t.Setenv. +func devModeEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(DevModeEnvVar))) + switch v { + case "1", "true", "yes", "on": + return true + default: + return false } - return nil } // Credentials is the per-tenant blob stashed in the secrets manager under @@ -137,9 +104,14 @@ type Credentials struct { // The provider passes this string verbatim as the Bucket parameter on // every S3 SDK call. AccessPointARN string - // Region overrides Config.Region for this tenant. Optional; useful if - // the deployment grows multi-region without rolling a new config. + // Region the AP lives in. Region string + // BaseRoleARN is the IAM role assumed via STS to mint per-request, + // per-tenant scoped credentials. Stored per-tenant (not per-deployment) + // so a single chainloop install can serve tenants across multiple AWS + // accounts without a config change. Required unless DevModeEnvVar is + // set on the running binary. + BaseRoleARN string } func (c *Credentials) Validate() error { @@ -152,42 +124,39 @@ func (c *Credentials) Validate() error { if !strings.HasPrefix(c.AccessPointARN, "arn:aws:s3:") || !strings.Contains(c.AccessPointARN, ":accesspoint/") { return fmt.Errorf("%w: access_point_arn %q is not an S3 access point ARN", backend.ErrValidation, c.AccessPointARN) } + if c.Region == "" { + return fmt.Errorf("%w: missing region", backend.ErrValidation) + } + if !devModeEnabled() { + if c.BaseRoleARN == "" { + return fmt.Errorf("%w: missing base_role_arn", backend.ErrValidation) + } + if !strings.HasPrefix(c.BaseRoleARN, "arn:aws:iam::") { + return fmt.Errorf("%w: base_role_arn %q is not a valid IAM role ARN", backend.ErrValidation, c.BaseRoleARN) + } + } return nil } // BackendProvider implements backend.Provider for the access-point-backed -// managed CAS. Construction validates the deployment Config so a -// misconfigured controlplane fails at startup rather than at first upload. +// managed CAS. Construction takes only the credentials reader; everything +// the provider needs at request time lives in the per-tenant secret blob. type BackendProvider struct { - cfg *Config cReader credentials.Reader } var _ backend.Provider = (*BackendProvider)(nil) -// NewBackendProvider constructs the provider. It returns an error if cfg -// is missing required fields; callers (typically loader.LoadProviders) are -// expected to skip registration on error so on-prem deployments without -// managed CAS aren't affected. -func NewBackendProvider(cfg *Config, cReader credentials.Reader) (*BackendProvider, error) { - if err := cfg.Validate(); err != nil { - return nil, err - } +// NewBackendProvider constructs the provider. A nil credentials reader is +// a programmer error and surfaces as a startup failure. +func NewBackendProvider(cReader credentials.Reader) (*BackendProvider, error) { if cReader == nil { return nil, errors.New("s3accesspoint: credentials reader is required") } - // Normalize default session duration so downstream code can rely on a - // non-zero value without re-checking everywhere. - if cfg.SessionDuration == 0 { - cfg.SessionDuration = DefaultSessionDuration - } - // Loud warning at startup so misconfiguration is obvious in logs. We - // use the std log here because the kratos logger isn't plumbed down - // to this package by design — keeping the provider portable. - if cfg.DevModeUseAmbientCredentials { - log.Printf("WARNING: s3accesspoint provider configured with DevModeUseAmbientCredentials=true; sts:AssumeRole is bypassed and per-tenant isolation is NOT enforced — DEV USE ONLY") + if devModeEnabled() { + log.Printf("WARNING: s3accesspoint provider running with %s=true; sts:AssumeRole is bypassed and per-tenant isolation is NOT enforced — DEV USE ONLY", DevModeEnvVar) } - return &BackendProvider{cfg: cfg, cReader: cReader}, nil + return &BackendProvider{cReader: cReader}, nil } func (p *BackendProvider) ID() string { @@ -208,7 +177,7 @@ func (p *BackendProvider) FromCredentials(ctx context.Context, secretName string if err := creds.Validate(); err != nil { return nil, fmt.Errorf("invalid credentials retrieved from storage: %w", err) } - return NewBackend(ctx, p.cfg, creds) + return NewBackend(ctx, creds) } // ValidateAndExtractCredentials decodes credsJSON into a Credentials struct @@ -231,9 +200,6 @@ func (p *BackendProvider) ValidateAndExtractCredentials(location string, credsJS if err := creds.Validate(); err != nil { return nil, fmt.Errorf("invalid credentials: %w", err) } - // If the caller supplied a location, it must agree with the blob. - // This is a denormalization sanity check, not a security boundary — - // the security boundary is the AP resource policy on the AWS side. if location != "" && location != creds.AccessPointARN { return nil, fmt.Errorf("%w: location %q does not match access_point_arn %q", backend.ErrValidation, location, creds.AccessPointARN) diff --git a/pkg/blobmanager/s3accesspoint/provider_test.go b/pkg/blobmanager/s3accesspoint/provider_test.go index 89741cdc3..a607f8315 100644 --- a/pkg/blobmanager/s3accesspoint/provider_test.go +++ b/pkg/blobmanager/s3accesspoint/provider_test.go @@ -19,7 +19,6 @@ import ( "context" "encoding/json" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -32,36 +31,11 @@ func validCreds() Credentials { return Credentials{ AccessPointARN: "arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org-abc", Region: "us-east-1", - } -} - -func TestConfig_Validate(t *testing.T) { - t.Parallel() - tests := []struct { - name string - cfg *Config - wantErr string - }{ - {"nil config", nil, "nil config"}, - {"missing role arn", &Config{Region: "us-east-1"}, "base_role_arn is required"}, - {"malformed role arn", &Config{BaseRoleARN: "not-an-arn", Region: "us-east-1"}, "not a valid IAM role ARN"}, - {"missing region", &Config{BaseRoleARN: "arn:aws:iam::1:role/r"}, "region is required"}, - {"happy", &Config{BaseRoleARN: "arn:aws:iam::1:role/r", Region: "us-east-1"}, ""}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := tc.cfg.Validate() - if tc.wantErr == "" { - assert.NoError(t, err) - return - } - assert.ErrorContains(t, err, tc.wantErr) - }) + BaseRoleARN: "arn:aws:iam::123456789012:role/chainloop-cas-tenant", } } func TestCredentials_Validate(t *testing.T) { - t.Parallel() tests := []struct { name string mutate func(*Credentials) @@ -78,6 +52,21 @@ func TestCredentials_Validate(t *testing.T) { mutate: func(c *Credentials) { c.AccessPointARN = "arn:aws:s3:::some-bucket" }, wantErr: "not an S3 access point ARN", }, + { + name: "missing region", + mutate: func(c *Credentials) { c.Region = "" }, + wantErr: "missing region", + }, + { + name: "missing base role arn", + mutate: func(c *Credentials) { c.BaseRoleARN = "" }, + wantErr: "missing base_role_arn", + }, + { + name: "malformed base role arn", + mutate: func(c *Credentials) { c.BaseRoleARN = "not-an-arn" }, + wantErr: "not a valid IAM role ARN", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -93,13 +82,31 @@ func TestCredentials_Validate(t *testing.T) { } } +// In dev mode the base role requirement is relaxed because nothing on the +// hot path will actually call sts:AssumeRole. AccessPointARN and Region +// remain mandatory — the SDK needs the latter to construct any S3 client +// at all. +func TestCredentials_Validate_DevModeRelaxesBaseRoleARN(t *testing.T) { + // Without dev mode: empty base role rejected. + c := validCreds() + c.BaseRoleARN = "" + require.ErrorContains(t, c.Validate(), "missing base_role_arn") + + // With dev mode: empty base role accepted. + t.Setenv(DevModeEnvVar, "true") + require.NoError(t, c.Validate()) + + // AccessPointARN is still mandatory in dev mode. + c2 := validCreds() + c2.AccessPointARN = "" + require.ErrorContains(t, c2.Validate(), "missing access_point_arn") +} + func TestValidateAndExtractCredentials(t *testing.T) { t.Parallel() good := validCreds() goodJSON, _ := json.Marshal(good) - - // Same content but mismatched location passed alongside. wrongLocation := good.AccessPointARN + "-tampered" tests := []struct { @@ -116,9 +123,7 @@ func TestValidateAndExtractCredentials(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - p := &BackendProvider{cfg: &Config{ - BaseRoleARN: "arn:aws:iam::1:role/r", Region: "us-east-1", - }} + p := &BackendProvider{cReader: stubReader{}} out, err := p.ValidateAndExtractCredentials(tc.location, tc.body) if tc.wantErr != "" { assert.ErrorContains(t, err, tc.wantErr) @@ -130,53 +135,20 @@ func TestValidateAndExtractCredentials(t *testing.T) { require.True(t, ok, "expected *Credentials, got %T", out) assert.Equal(t, good.AccessPointARN, creds.AccessPointARN) assert.Equal(t, good.Region, creds.Region) + assert.Equal(t, good.BaseRoleARN, creds.BaseRoleARN) }) } } -func TestNewBackendProvider_NormalizesSessionDuration(t *testing.T) { - cfg := &Config{ - BaseRoleARN: "arn:aws:iam::1:role/r", - Region: "us-east-1", - // Intentionally zero — provider should fill the default. - } - p, err := NewBackendProvider(cfg, stubReader{}) - require.NoError(t, err) - assert.Equal(t, ProviderID, p.ID()) - assert.Equal(t, DefaultSessionDuration, p.cfg.SessionDuration) - - custom := 5 * time.Minute - cfg2 := &Config{BaseRoleARN: cfg.BaseRoleARN, Region: cfg.Region, SessionDuration: custom} - p2, err := NewBackendProvider(cfg2, stubReader{}) - require.NoError(t, err) - assert.Equal(t, custom, p2.cfg.SessionDuration) -} - -func TestNewBackendProvider_FailsOnBadConfig(t *testing.T) { - _, err := NewBackendProvider(&Config{Region: "us-east-1"}, stubReader{}) - assert.ErrorContains(t, err, "base_role_arn") - - _, err = NewBackendProvider(&Config{BaseRoleARN: "arn:aws:iam::1:role/r", Region: "us-east-1"}, nil) - assert.ErrorContains(t, err, "credentials reader is required") -} - -// Dev mode relaxes the base_role_arn requirement because nothing on the -// hot path will actually call sts:AssumeRole. Region is still required — -// the SDK config needs it to construct any S3 client at all. -func TestConfig_Validate_DevModeRelaxesBaseRoleARN(t *testing.T) { +func TestNewBackendProvider(t *testing.T) { t.Parallel() - // Without dev mode: empty base role rejected. - err := (&Config{Region: "us-east-1"}).Validate() - require.ErrorContains(t, err, "base_role_arn is required") - - // With dev mode: empty base role accepted. - err = (&Config{Region: "us-east-1", DevModeUseAmbientCredentials: true}).Validate() + p, err := NewBackendProvider(stubReader{}) require.NoError(t, err) + assert.Equal(t, ProviderID, p.ID()) - // Region is still mandatory in dev mode. - err = (&Config{DevModeUseAmbientCredentials: true}).Validate() - require.ErrorContains(t, err, "region is required") + _, err = NewBackendProvider(nil) + assert.ErrorContains(t, err, "credentials reader is required") } // stubReader is the minimal credentials.Reader implementation needed to From 462db4064bfaef79824c25ae20387107aefd8f3f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 19:21:51 +0200 Subject: [PATCH 13/18] chore: mark *.pb.go as generated in .gitattributes Collapses protobuf bindings in PR diffs and excludes them from linguist language stats. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 Signed-off-by: Jose I. Paris --- .gitattributes | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 8a936351a..3880cde0f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,4 +4,7 @@ app/controlplane/pkg/data/ent/migrate/** linguist-generated=false app/controlplane/pkg/data/ent/migrate/** linguist-detectable=true app/controlplane/pkg/data/ent/schema/* linguist-generated=false app/controlplane/pkg/data/ent/schema/* linguist-detectable=true -app/controlplane/api/gen/jsonschema/** linguist-generated=true \ No newline at end of file +app/controlplane/api/gen/jsonschema/** linguist-generated=true +*.pb.go linguist-generated=true +*.pb.validate.go linguist-generated=true +wire_gen.go linguist-generated=true \ No newline at end of file From 78e27d43299901685373665bde834c94b5696f19 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 19:35:19 +0200 Subject: [PATCH 14/18] remove comments Signed-off-by: Jose I. Paris --- pkg/blobmanager/s3accesspoint/backend.go | 9 +++------ pkg/blobmanager/s3accesspoint/provider.go | 14 ++++++-------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/pkg/blobmanager/s3accesspoint/backend.go b/pkg/blobmanager/s3accesspoint/backend.go index e9d639c5e..33a42d829 100644 --- a/pkg/blobmanager/s3accesspoint/backend.go +++ b/pkg/blobmanager/s3accesspoint/backend.go @@ -45,7 +45,7 @@ const ( // without an org UUID in its context. The backend fails closed in this // case rather than minting a session with a default/empty name that would // be useless against an AP policy condition. -var ErrMissingRequestingOrg = errors.New("s3accesspoint: requesting org missing from context (call WithRequestingOrg before upload/download)") +var ErrMissingRequestingOrg = errors.New("s3accesspoint: requesting org missing from claims") // Backend is the per-tenant uploader/downloader. One *Backend instance is // bound to one access point; the actual AWS credentials are minted @@ -224,8 +224,7 @@ func (b *Backend) Download(ctx context.Context, w io.Writer, digest string) erro // CheckWritePermissions verifies that the calling org can actually mint a // scoped session and put/get an object through its AP. Unlike the regular -// s3 backend's variant this MUST be invoked with a context carrying -// WithRequestingOrg; otherwise it fails closed. +// s3 backend's variant this MUST be invoked with a context carrying the org func (b *Backend) CheckWritePermissions(ctx context.Context) error { info, err := robotaccount.InfoFromAuth(ctx) if err != nil { @@ -289,9 +288,7 @@ func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credenti } // Dev mode: skip the per-request AssumeRole entirely and use the - // SDK's default credential chain directly. We still required the - // org-from-ctx check above so callers that forget WithRequestingOrg - // fail the same way they would in production. + // SDK's default credential chain directly. if p.useAmbientForRetrieve { if p.ambientCreds == nil { return aws.Credentials{}, errors.New("s3accesspoint: dev mode requested but no ambient credentials available") diff --git a/pkg/blobmanager/s3accesspoint/provider.go b/pkg/blobmanager/s3accesspoint/provider.go index 69a55cd25..5f7b367be 100644 --- a/pkg/blobmanager/s3accesspoint/provider.go +++ b/pkg/blobmanager/s3accesspoint/provider.go @@ -20,8 +20,7 @@ // 1. The Access Point's resource policy, which gates who can address the AP // and may further restrict s3:prefix. // 2. A per-request sts:AssumeRole that mints a scoped session whose -// RoleSessionName is derived from the authenticated requesting org -// (carried in the request context via WithRequestingOrg). The AP's +// RoleSessionName is derived from the authenticated requesting org. The AP's // resource policy enforces a StringEquals on aws:userid so that a // session minted for org A cannot read or write to org B's AP — even if // org A's secret blob has been tampered with to point at org B's ARN. @@ -34,7 +33,7 @@ // // The session name MUST come from the request context, not from the secret // blob: a secrets-store compromise alone must not let an attacker reroute -// uploads to another tenant's AP. See WithRequestingOrg. +// uploads to another tenant's AP. package s3accesspoint import ( @@ -65,8 +64,7 @@ const SessionDuration = time.Hour // and routes S3 calls through whatever ambient AWS identity the SDK's // default credential chain produced (env vars, ~/.aws/credentials, instance // profile, IRSA, …). The fail-closed check on a missing requesting-org -// context is still enforced so callers that forget WithRequestingOrg get -// the same error locally as they would in production. +// context is still enforced. // // DEV ONLY. This bypasses the per-tenant isolation guarantees that the // AssumeRole + session-policy + AP-policy chain provides; objects @@ -93,7 +91,7 @@ func devModeEnabled() bool { // // The per-tenant key prefix is intentionally NOT a field here: it's // derived at request time from the authenticated requesting org carried -// in ctx via WithRequestingOrg. Both the bucket-layer key namespace and +// in ctx via org claim. Both the bucket-layer key namespace and // the AssumeRole session-name binding therefore come from the same // untamperable source, so a secrets-store compromise that rewrites this // blob still can't reroute a tenant's writes into another tenant's @@ -167,7 +165,7 @@ func (p *BackendProvider) ID() string { // manager and constructs a *Backend bound to that tenant's AP. // // The returned UploaderDownloader is safe to reuse across requests; each -// request must enrich its context with WithRequestingOrg so the STS-minted +// request must enrich its context with org claim so the STS-minted // session name matches the AP's resource-policy condition. func (p *BackendProvider) FromCredentials(ctx context.Context, secretName string) (backend.UploaderDownloader, error) { creds := &Credentials{} @@ -188,7 +186,7 @@ func (p *BackendProvider) FromCredentials(ctx context.Context, secretName string // // Unlike the regular s3 provider, this does NOT exercise live S3 // permissions during validation: the credentials by themselves can't be -// tested without a request-context org UUID (see WithRequestingOrg), so a +// tested without a request-context org UUID, so a // proper end-to-end check belongs in the upload path. PerformValidation in // the controlplane still calls this method for managed rows; it will // succeed as long as the blob is well-formed. From 2fe7d0be082f87da50c98119b8c80d4c68fbf19a Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 19:36:35 +0200 Subject: [PATCH 15/18] remove comment Signed-off-by: Jose I. Paris --- pkg/blobmanager/s3accesspoint/backend_test.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pkg/blobmanager/s3accesspoint/backend_test.go b/pkg/blobmanager/s3accesspoint/backend_test.go index 6b22befaf..587101373 100644 --- a/pkg/blobmanager/s3accesspoint/backend_test.go +++ b/pkg/blobmanager/s3accesspoint/backend_test.go @@ -28,15 +28,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestBackend_FailClosedWithoutRequestingOrg is the load-bearing fail- -// closed test: any backend operation that would normally hit AWS must -// refuse to even attempt the call when the caller forgot to enrich the -// context with backend.WithRequestingOrg. This test does NOT need LocalStack — -// the credential provider rejects the request before any AWS SDK code -// runs. -// -// Not parallel: uses t.Setenv to fence the AWS SDK off from the real -// credential chain. func TestBackend_FailClosedWithoutRequestingOrg(t *testing.T) { b := newTestBackend(t) ctx := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) From 9b0c9a5e056413f5db378de1fd7445d934c0ddc6 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 19:51:12 +0200 Subject: [PATCH 16/18] remove reader check Signed-off-by: Jose I. Paris --- pkg/blobmanager/loader/loader.go | 7 +------ pkg/blobmanager/s3accesspoint/provider.go | 8 ++------ pkg/blobmanager/s3accesspoint/provider_test.go | 6 +----- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/pkg/blobmanager/loader/loader.go b/pkg/blobmanager/loader/loader.go index 19a5fcc82..c32482601 100644 --- a/pkg/blobmanager/loader/loader.go +++ b/pkg/blobmanager/loader/loader.go @@ -34,12 +34,7 @@ func LoadProviders(creader credentials.Reader) backends.Providers { ociProvider := oci.NewBackendProvider(creader) azureBlobProvider := azureblob.NewBackendProvider(creader) s3Provider := s3.NewBackendProvider(creader) - apProvider, err := s3accesspoint.NewBackendProvider(creader) - if err != nil { - // Only fails on a nil credentials reader, which is a programmer - // error caught at startup wiring just like the other providers. - panic(err) - } + apProvider := s3accesspoint.NewBackendProvider(creader) return backends.Providers{ ociProvider.ID(): ociProvider, diff --git a/pkg/blobmanager/s3accesspoint/provider.go b/pkg/blobmanager/s3accesspoint/provider.go index 5f7b367be..6dab7d1d6 100644 --- a/pkg/blobmanager/s3accesspoint/provider.go +++ b/pkg/blobmanager/s3accesspoint/provider.go @@ -39,7 +39,6 @@ package s3accesspoint import ( "context" "encoding/json" - "errors" "fmt" "log" "os" @@ -147,14 +146,11 @@ var _ backend.Provider = (*BackendProvider)(nil) // NewBackendProvider constructs the provider. A nil credentials reader is // a programmer error and surfaces as a startup failure. -func NewBackendProvider(cReader credentials.Reader) (*BackendProvider, error) { - if cReader == nil { - return nil, errors.New("s3accesspoint: credentials reader is required") - } +func NewBackendProvider(cReader credentials.Reader) *BackendProvider { if devModeEnabled() { log.Printf("WARNING: s3accesspoint provider running with %s=true; sts:AssumeRole is bypassed and per-tenant isolation is NOT enforced — DEV USE ONLY", DevModeEnvVar) } - return &BackendProvider{cReader: cReader}, nil + return &BackendProvider{cReader: cReader} } func (p *BackendProvider) ID() string { diff --git a/pkg/blobmanager/s3accesspoint/provider_test.go b/pkg/blobmanager/s3accesspoint/provider_test.go index a607f8315..ccc1d9488 100644 --- a/pkg/blobmanager/s3accesspoint/provider_test.go +++ b/pkg/blobmanager/s3accesspoint/provider_test.go @@ -143,12 +143,8 @@ func TestValidateAndExtractCredentials(t *testing.T) { func TestNewBackendProvider(t *testing.T) { t.Parallel() - p, err := NewBackendProvider(stubReader{}) - require.NoError(t, err) + p := NewBackendProvider(stubReader{}) assert.Equal(t, ProviderID, p.ID()) - - _, err = NewBackendProvider(nil) - assert.ErrorContains(t, err, "credentials reader is required") } // stubReader is the minimal credentials.Reader implementation needed to From 4c632d04c1e47448e3bffceff5042ae2e351ec9e Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 20:03:58 +0200 Subject: [PATCH 17/18] refactor(robotaccount): make orgID mandatory in CAS JWT Promote orgID to a required field on the CAS JWT alongside backendType, secretID, audience. Plumb it through CASClient.Upload/Download and the dispatcher's loadInputs so all call sites supply the org explicitly rather than relying on uuid.Nil as an "absent" sentinel. Non-managed providers ignore the claim; managed providers (AWS-S3-ACCESS-POINT) keep using it to scope per-tenant STS sessions. The token also gains audit traceability for free. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2 --- app/artifact-cas/internal/server/grpc_test.go | 2 +- .../internal/dispatcher/dispatcher.go | 10 ++++++--- .../internal/dispatcher/dispatcher_test.go | 9 ++++---- .../internal/service/workflowrun.go | 2 +- app/controlplane/pkg/biz/attestation.go | 2 +- app/controlplane/pkg/biz/casclient.go | 13 ++++++------ app/controlplane/pkg/biz/cascredentials.go | 16 +++++++------- app/controlplane/pkg/biz/mocks/CASClient.go | 21 ++++++++++--------- app/controlplane/pkg/biz/workflowrun.go | 2 +- internal/robotaccount/cas/robotaccount.go | 21 ++++++++++++------- 10 files changed, 55 insertions(+), 43 deletions(-) diff --git a/app/artifact-cas/internal/server/grpc_test.go b/app/artifact-cas/internal/server/grpc_test.go index 4d4b9e767..fcc7b5281 100644 --- a/app/artifact-cas/internal/server/grpc_test.go +++ b/app/artifact-cas/internal/server/grpc_test.go @@ -97,7 +97,7 @@ func TestJWTAuthFunc(t *testing.T) { b, err := robotaccount.NewBuilder(opts...) require.NoError(t, err) - token, err := b.GenerateJWT("backend-type", "secret-id", tc.audience, robotaccount.Downloader, 0, "") + token, err := b.GenerateJWT("backend-type", "secret-id", tc.audience, robotaccount.Downloader, 0, "org-id") require.NoError(t, err) // add bearer token to context diff --git a/app/controlplane/internal/dispatcher/dispatcher.go b/app/controlplane/internal/dispatcher/dispatcher.go index 49305e134..704bb6b2d 100644 --- a/app/controlplane/internal/dispatcher/dispatcher.go +++ b/app/controlplane/internal/dispatcher/dispatcher.go @@ -92,7 +92,7 @@ func (d *FanOutDispatcher) Run(ctx context.Context, opts *RunOpts) error { } // 2. Hydrate the dispatch queue with the actual inputs - if err := d.loadInputs(ctx, queue, opts.Envelope, opts.DownloadBackendType, opts.DownloadSecretName); err != nil { + if err := d.loadInputs(ctx, queue, opts.Envelope, opts.DownloadBackendType, opts.DownloadSecretName, opts.OrgID); err != nil { return fmt.Errorf("loading materials: %w", err) } @@ -198,7 +198,7 @@ func (d *FanOutDispatcher) initDispatchQueue(ctx context.Context, orgID, workflo } // Load the inputs for the dispatchItem, both materials and attestation -func (d *FanOutDispatcher) loadInputs(ctx context.Context, queue dispatchQueue, att *dsse.Envelope, backendType, secretName string) error { +func (d *FanOutDispatcher) loadInputs(ctx context.Context, queue dispatchQueue, att *dsse.Envelope, backendType, secretName, orgID string) error { if att == nil { return fmt.Errorf("attestation is nil") } @@ -252,8 +252,12 @@ func (d *FanOutDispatcher) loadInputs(ctx context.Context, queue dispatchQueue, if item.plugin.IsSubscribedTo(material.Type) { // It's a downloadable and has not been downloaded yet if !downloaded && material.Hash != nil && material.UploadedToCAS { + orgUUID, err := uuid.Parse(orgID) + if err != nil { + return fmt.Errorf("parsing org id: %w", err) + } buf := bytes.NewBuffer(nil) - if err := d.casClient.Download(ctx, backendType, secretName, buf, material.Hash.String()); err != nil { + if err := d.casClient.Download(ctx, backendType, secretName, orgUUID, buf, material.Hash.String()); err != nil { return fmt.Errorf("downloading from CAS: %w", err) } diff --git a/app/controlplane/internal/dispatcher/dispatcher_test.go b/app/controlplane/internal/dispatcher/dispatcher_test.go index 0a10c1112..ff971c7aa 100644 --- a/app/controlplane/internal/dispatcher/dispatcher_test.go +++ b/app/controlplane/internal/dispatcher/dispatcher_test.go @@ -29,6 +29,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" mockedSDK "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1/mocks" "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" "github.com/secure-systems-lab/go-securesystemslib/dsse" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" @@ -57,7 +58,7 @@ func (s *dispatcherTestSuite) TestLoadInputsEnvelope() { s.ociIntegrationBackend.(*mockedSDK.FanOut).On("IsSubscribedTo", "SBOM_CYCLONEDX_JSON").Return(false) s.ociIntegrationBackend.(*mockedSDK.FanOut).On("String").Return("mocked-integration") - err = s.dispatcher.loadInputs(context.TODO(), queue, envelope, "backend-type", "secret-name") + err = s.dispatcher.loadInputs(context.TODO(), queue, envelope, "backend-type", "secret-name", uuid.NewString()) assert.NoError(s.T(), err) // Only one integration is registered @@ -101,14 +102,14 @@ func (s *dispatcherTestSuite) TestLoadInputsMaterials() { require.NoError(s.T(), err) // Simulate SBOM download - s.casClient.On("Download", mock.Anything, "backend-type", "secret-name", mock.Anything, mock.Anything). + s.casClient.On("Download", mock.Anything, "backend-type", "secret-name", mock.Anything, mock.Anything, mock.Anything). Return(nil).Run(func(args mock.Arguments) { buf := bytes.NewBuffer([]byte("SBOM Content")) - _, err := io.Copy(args.Get(3).(io.Writer), buf) + _, err := io.Copy(args.Get(4).(io.Writer), buf) s.NoError(err) }) - err = s.dispatcher.loadInputs(context.TODO(), queue, envelope, "backend-type", "secret-name") + err = s.dispatcher.loadInputs(context.TODO(), queue, envelope, "backend-type", "secret-name", uuid.NewString()) assert.NoError(s.T(), err) require.Len(s.T(), queue, 3) diff --git a/app/controlplane/internal/service/workflowrun.go b/app/controlplane/internal/service/workflowrun.go index 27d1e51b4..4934ed6e5 100644 --- a/app/controlplane/internal/service/workflowrun.go +++ b/app/controlplane/internal/service/workflowrun.go @@ -109,7 +109,7 @@ func (s *WorkflowRunService) resolvePolicyEvaluations( } var buf bytes.Buffer - if err := s.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, &buf, digest); err != nil { + if err := s.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, mapping.CASBackend.OrganizationID, &buf, digest); err != nil { return nil, fmt.Errorf("downloading policy eval bundle: %w", err) } diff --git a/app/controlplane/pkg/biz/attestation.go b/app/controlplane/pkg/biz/attestation.go index 19967c477..02b696017 100644 --- a/app/controlplane/pkg/biz/attestation.go +++ b/app/controlplane/pkg/biz/attestation.go @@ -49,7 +49,7 @@ func (uc *AttestationUseCase) UploadAttestationToCAS(ctx context.Context, conten ctx, span := otelx.Start(ctx, attestationTracer, "AttestationUseCase.UploadAttestationToCAS") defer span.End() - if err := uc.CASClient.Upload(ctx, string(backend.Provider), backend.SecretName, bytes.NewBuffer(content), fmt.Sprintf("attestation-%s.json", workflowRunID), digest.String()); err != nil { + if err := uc.CASClient.Upload(ctx, string(backend.Provider), backend.SecretName, backend.OrganizationID, bytes.NewBuffer(content), fmt.Sprintf("attestation-%s.json", workflowRunID), digest.String()); err != nil { otelx.RecordError(span, err) return err } diff --git a/app/controlplane/pkg/biz/casclient.go b/app/controlplane/pkg/biz/casclient.go index 6cae2b7a2..98f7856de 100644 --- a/app/controlplane/pkg/biz/casclient.go +++ b/app/controlplane/pkg/biz/casclient.go @@ -30,6 +30,7 @@ import ( "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/chainloop-dev/chainloop/pkg/servicelogger" "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" ) var casClientTracer = otelx.Tracer("chainloop-controlplane", "biz/casclient") @@ -45,11 +46,11 @@ type CASClientUseCase struct { } type CASUploader interface { - Upload(ctx context.Context, backendType, secretID string, content io.Reader, filename, digest string) error + Upload(ctx context.Context, backendType, secretID string, orgID uuid.UUID, content io.Reader, filename, digest string) error } type CASDownloader interface { - Download(ctx context.Context, backendType, secretID string, w io.Writer, digest string) error + Download(ctx context.Context, backendType, secretID string, orgID uuid.UUID, w io.Writer, digest string) error } type CASClient interface { @@ -102,14 +103,14 @@ func NewCASClientUseCase(credsProvider *CASCredentialsUseCase, config *conf.Boot } // The secretID is embedded in the JWT token and is used to identify the secret by the CAS server -func (uc *CASClientUseCase) Upload(ctx context.Context, backendType, secretID string, content io.Reader, filename, digest string) error { +func (uc *CASClientUseCase) Upload(ctx context.Context, backendType, secretID string, orgID uuid.UUID, content io.Reader, filename, digest string) error { ctx, span := otelx.Start(ctx, casClientTracer, "CASClientUseCase.Upload") defer span.End() uc.logger.Infow("msg", "upload initialized", "filename", filename, "digest", digest) // client with temporary set of credentials - client, closeFn, err := uc.casAPIClient(&CASCredsOpts{BackendType: backendType, SecretPath: secretID, Role: casJWT.Uploader}) + client, closeFn, err := uc.casAPIClient(&CASCredsOpts{BackendType: backendType, SecretPath: secretID, Role: casJWT.Uploader, OrgID: orgID}) if err != nil { return fmt.Errorf("failed to create cas client: %w", err) } @@ -125,13 +126,13 @@ func (uc *CASClientUseCase) Upload(ctx context.Context, backendType, secretID st return nil } -func (uc *CASClientUseCase) Download(ctx context.Context, backendType, secretID string, w io.Writer, digest string) error { +func (uc *CASClientUseCase) Download(ctx context.Context, backendType, secretID string, orgID uuid.UUID, w io.Writer, digest string) error { ctx, span := otelx.Start(ctx, casClientTracer, "CASClientUseCase.Download") defer span.End() uc.logger.Infow("msg", "download initialized", "digest", digest) - client, closeFn, err := uc.casAPIClient(&CASCredsOpts{BackendType: backendType, SecretPath: secretID, Role: casJWT.Downloader}) + client, closeFn, err := uc.casAPIClient(&CASCredsOpts{BackendType: backendType, SecretPath: secretID, Role: casJWT.Downloader, OrgID: orgID}) if err != nil { return fmt.Errorf("failed to create cas client: %w", err) } diff --git a/app/controlplane/pkg/biz/cascredentials.go b/app/controlplane/pkg/biz/cascredentials.go index 5b3e08680..41e1360bb 100644 --- a/app/controlplane/pkg/biz/cascredentials.go +++ b/app/controlplane/pkg/biz/cascredentials.go @@ -16,6 +16,7 @@ package biz import ( + "fmt" "time" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" @@ -49,17 +50,16 @@ type CASCredsOpts struct { SecretPath string // path to for example the OCI secret in the vault Role robotaccount.Role MaxBytes int64 - // OrgID is the org the CAS backend belongs to. Required for managed - // backends (e.g. AWS-S3-ACCESS-POINT) that need to scope per-tenant - // STS sessions; uuid.Nil is treated as "absent" for the others - // (OCI, S3, AzureBlob). + // OrgID identifies the org the CAS backend belongs to. Required for + // every CAS JWT — managed providers (e.g. AWS-S3-ACCESS-POINT) need + // it to scope per-tenant STS sessions, and non-managed providers + // still carry it for audit traceability. OrgID uuid.UUID } func (uc *CASCredentialsUseCase) GenerateTemporaryCredentials(backendRef *CASCredsOpts) (string, error) { - var orgID string - if backendRef.OrgID != uuid.Nil { - orgID = backendRef.OrgID.String() + if backendRef.OrgID == uuid.Nil { + return "", fmt.Errorf("org id is required") } - return uc.jwtBuilder.GenerateJWT(backendRef.BackendType, backendRef.SecretPath, jwt.CASAudience, backendRef.Role, backendRef.MaxBytes, orgID) + return uc.jwtBuilder.GenerateJWT(backendRef.BackendType, backendRef.SecretPath, jwt.CASAudience, backendRef.Role, backendRef.MaxBytes, backendRef.OrgID.String()) } diff --git a/app/controlplane/pkg/biz/mocks/CASClient.go b/app/controlplane/pkg/biz/mocks/CASClient.go index ba51e6129..66ebacc70 100644 --- a/app/controlplane/pkg/biz/mocks/CASClient.go +++ b/app/controlplane/pkg/biz/mocks/CASClient.go @@ -6,6 +6,7 @@ import ( context "context" io "io" + "github.com/google/uuid" mock "github.com/stretchr/testify/mock" ) @@ -14,17 +15,17 @@ type CASClient struct { mock.Mock } -// Download provides a mock function with given fields: ctx, backendType, secretID, w, digest -func (_m *CASClient) Download(ctx context.Context, backendType string, secretID string, w io.Writer, digest string) error { - ret := _m.Called(ctx, backendType, secretID, w, digest) +// Download provides a mock function with given fields: ctx, backendType, secretID, orgID, w, digest +func (_m *CASClient) Download(ctx context.Context, backendType string, secretID string, orgID uuid.UUID, w io.Writer, digest string) error { + ret := _m.Called(ctx, backendType, secretID, orgID, w, digest) if len(ret) == 0 { panic("no return value specified for Download") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, io.Writer, string) error); ok { - r0 = rf(ctx, backendType, secretID, w, digest) + if rf, ok := ret.Get(0).(func(context.Context, string, string, uuid.UUID, io.Writer, string) error); ok { + r0 = rf(ctx, backendType, secretID, orgID, w, digest) } else { r0 = ret.Error(0) } @@ -32,17 +33,17 @@ func (_m *CASClient) Download(ctx context.Context, backendType string, secretID return r0 } -// Upload provides a mock function with given fields: ctx, backendType, secretID, content, filename, digest -func (_m *CASClient) Upload(ctx context.Context, backendType string, secretID string, content io.Reader, filename string, digest string) error { - ret := _m.Called(ctx, backendType, secretID, content, filename, digest) +// Upload provides a mock function with given fields: ctx, backendType, secretID, orgID, content, filename, digest +func (_m *CASClient) Upload(ctx context.Context, backendType string, secretID string, orgID uuid.UUID, content io.Reader, filename string, digest string) error { + ret := _m.Called(ctx, backendType, secretID, orgID, content, filename, digest) if len(ret) == 0 { panic("no return value specified for Upload") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, string, string) error); ok { - r0 = rf(ctx, backendType, secretID, content, filename, digest) + if rf, ok := ret.Get(0).(func(context.Context, string, string, uuid.UUID, io.Reader, string, string) error); ok { + r0 = rf(ctx, backendType, secretID, orgID, content, filename, digest) } else { r0 = ret.Error(0) } diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index 084081cc1..be88d561e 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -708,7 +708,7 @@ func (uc *WorkflowRunUseCase) downloadBundleFromCAS(ctx context.Context, digest } var buf bytes.Buffer - if err := uc.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, &buf, digest); err != nil { + if err := uc.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, mapping.CASBackend.OrganizationID, &buf, digest); err != nil { return nil, fmt.Errorf("downloading attestation bundle: %w", err) } diff --git a/internal/robotaccount/cas/robotaccount.go b/internal/robotaccount/cas/robotaccount.go index 3a38570ef..223f69b58 100644 --- a/internal/robotaccount/cas/robotaccount.go +++ b/internal/robotaccount/cas/robotaccount.go @@ -42,9 +42,10 @@ type Claims struct { BackendType string `json:"backend"` // backend to use, i.e OCI MaxBytes int64 `json:"maxbytes"` // max bytes to upload // OrgID identifies the authenticated org this token was minted for. - // Required for managed providers (AWS-S3-ACCESS-POINT) that need to - // scope per-tenant STS sessions; - OrgID string `json:"org-id,omitempty"` + // Managed providers (e.g. AWS-S3-ACCESS-POINT) require it to scope + // per-tenant STS sessions; the non-managed providers ignore it but + // it is still carried for audit traceability. + OrgID string `json:"org-id"` } type Role string @@ -110,11 +111,11 @@ func NewBuilder(opts ...NewOpt) (*Builder, error) { return b, nil } -// GenerateJWT mints a CAS token. orgID is required for tokens that will -// touch managed providers (e.g. AWS-S3-ACCESS-POINT) and otherwise -// optional — pass "" if the targeted backend doesn't need per-tenant -// attribution. The token always carries the CAS audience and a short -// expiry window. +// GenerateJWT mints a CAS token. All fields are required, including +// orgID — managed providers (e.g. AWS-S3-ACCESS-POINT) need it to scope +// per-tenant STS sessions and other providers still record it for +// audit. The token always carries the CAS audience and a short expiry +// window. func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role, maxBytes int64, orgID string) (string, error) { if backendType == "" { return "", fmt.Errorf("backend type is required") @@ -128,6 +129,10 @@ func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role return "", fmt.Errorf("audience is required") } + if orgID == "" { + return "", fmt.Errorf("org id is required") + } + if role != Downloader && role != Uploader { return "", fmt.Errorf("invalid role") } From aa0d30b7afee23b4a7dbe591bf93ad23bc58884c Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 18 May 2026 20:17:07 +0200 Subject: [PATCH 18/18] lint Signed-off-by: Jose I. Paris --- app/controlplane/pkg/biz/attestation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/attestation.go b/app/controlplane/pkg/biz/attestation.go index 02b696017..bf23186b1 100644 --- a/app/controlplane/pkg/biz/attestation.go +++ b/app/controlplane/pkg/biz/attestation.go @@ -49,7 +49,7 @@ func (uc *AttestationUseCase) UploadAttestationToCAS(ctx context.Context, conten ctx, span := otelx.Start(ctx, attestationTracer, "AttestationUseCase.UploadAttestationToCAS") defer span.End() - if err := uc.CASClient.Upload(ctx, string(backend.Provider), backend.SecretName, backend.OrganizationID, bytes.NewBuffer(content), fmt.Sprintf("attestation-%s.json", workflowRunID), digest.String()); err != nil { + if err := uc.Upload(ctx, string(backend.Provider), backend.SecretName, backend.OrganizationID, bytes.NewBuffer(content), fmt.Sprintf("attestation-%s.json", workflowRunID), digest.String()); err != nil { otelx.RecordError(span, err) return err }