Skip to content

Commit 8e2be4d

Browse files
committed
refactor(casbackend): tighten managed-CAS org plumbing per PR review
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 <jiparis@chainloop.dev> Chainloop-Trace-Sessions: 234a03ed-b238-4506-95f0-235242842db2
1 parent 0947249 commit 8e2be4d

17 files changed

Lines changed: 188 additions & 105 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package server
17+
18+
import (
19+
"context"
20+
nhttp "net/http"
21+
22+
"github.com/go-kratos/kratos/v2/middleware"
23+
jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt"
24+
25+
casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas"
26+
backend "github.com/chainloop-dev/chainloop/pkg/blobmanager"
27+
)
28+
29+
// withRequestingOrgFromClaims reads the CAS JWT claims (already
30+
// verified and stashed in ctx by the JWT middleware) and stamps the
31+
// org UUID on the context via backend.WithRequestingOrg. Managed CAS
32+
// providers consume this to scope per-tenant STS sessions; other
33+
// providers ignore it.
34+
//
35+
// A missing or empty OrgID is treated as "no managed binding" — ctx
36+
// passes through unchanged so legacy tokens minted before the org-id
37+
// claim was added continue to work for non-managed providers.
38+
func withRequestingOrgFromClaims(ctx context.Context) context.Context {
39+
raw, ok := jwtMiddleware.FromContext(ctx)
40+
if !ok {
41+
return ctx
42+
}
43+
claims, ok := raw.(*casJWT.Claims)
44+
if !ok || claims.OrgID == "" {
45+
return ctx
46+
}
47+
return backend.WithRequestingOrg(ctx, claims.OrgID)
48+
}
49+
50+
// requestingOrgMiddleware is a kratos middleware that runs after the
51+
// JWT middleware on unary gRPC requests and enriches ctx with the
52+
// requesting org from the verified claims.
53+
func requestingOrgMiddleware() middleware.Middleware {
54+
return func(handler middleware.Handler) middleware.Handler {
55+
return func(ctx context.Context, req any) (any, error) {
56+
return handler(withRequestingOrgFromClaims(ctx), req)
57+
}
58+
}
59+
}
60+
61+
// requestingOrgHTTPMiddleware wraps an HTTP handler so the request
62+
// context carries the requesting org. Apply it BETWEEN the JWT
63+
// middleware (which populates the claims in ctx) and the actual
64+
// handler.
65+
func requestingOrgHTTPMiddleware(next nhttp.Handler) nhttp.Handler {
66+
return nhttp.HandlerFunc(func(w nhttp.ResponseWriter, r *nhttp.Request) {
67+
next.ServeHTTP(w, r.WithContext(withRequestingOrgFromClaims(r.Context())))
68+
})
69+
}

app/artifact-cas/internal/server/grpc.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ func NewGRPCServer(c *conf.Server, authConf *conf.Auth, byteService *service.Byt
8686
loadPublicKey(rawKey),
8787
jwtMiddleware.WithSigningMethod(casJWT.SigningMethod),
8888
jwtMiddleware.WithClaims(func() jwt.Claims { return &casJWT.Claims{} })),
89+
// Runs after the JWT middleware on authenticated unary
90+
// RPCs; stamps the requesting-org UUID from the verified
91+
// claims onto ctx so managed CAS providers can scope STS
92+
// sessions per tenant.
93+
requestingOrgMiddleware(),
8994
).Match(requireAuthentication()).Build(),
9095
),
9196

@@ -183,7 +188,9 @@ func jwtAuthFunc(keyFunc jwt.Keyfunc, signingMethod jwt.SigningMethod) grpc_auth
183188
return nil, err
184189
}
185190

186-
return jwtMiddleware.NewContext(ctx, claims), nil
191+
ctx = jwtMiddleware.NewContext(ctx, claims)
192+
// Same enrichment the unary chain does via requestingOrgMiddleware.
193+
return withRequestingOrgFromClaims(ctx), nil
187194
}
188195
}
189196

app/artifact-cas/internal/server/http.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ func NewHTTPServer(c *conf.Server, authConf *conf.Auth, downloadSvc *service.Dow
6565

6666
srv := http.NewServer(opts...)
6767

68-
downloadHandler := middlewares_http.AuthFromQueryParam(loadPublicKey(rawKey), claimsFunc(), casJWT.SigningMethod, downloadSvc)
68+
// AuthFromQueryParam verifies the JWT and stashes the claims in
69+
// ctx; requestingOrgHTTPMiddleware then promotes the org-id claim
70+
// into a value consumable by managed CAS providers (mirrors the
71+
// gRPC chains).
72+
downloadHandler := middlewares_http.AuthFromQueryParam(loadPublicKey(rawKey), claimsFunc(), casJWT.SigningMethod, requestingOrgHTTPMiddleware(downloadSvc))
6973
srv.Handle(service.DownloadPath, CORSMiddleware(c.GetHttp().GetCors().GetAllowOrigins(), downloadHandler))
7074
api.RegisterStatusServiceHTTPServer(srv, service.NewStatusService(Version, providers))
7175
return srv, nil

app/artifact-cas/internal/service/bytestream.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func (s *ByteStreamService) Write(stream bytestream.ByteStream_WriteServer) erro
8080
return kerrors.BadRequest("resource name", err.Error())
8181
}
8282

83-
ctx, storageBackend, err := s.loadBackendForClaims(ctx, info)
83+
storageBackend, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID)
8484
if err != nil && kerrors.IsNotFound(err) {
8585
return err
8686
} else if err != nil {
@@ -161,7 +161,7 @@ func (s *ByteStreamService) Read(req *bytestream.ReadRequest, stream bytestream.
161161
return kerrors.BadRequest("resource name", "empty resource name")
162162
}
163163

164-
ctx, backend, err := s.loadBackendForClaims(ctx, info)
164+
backend, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID)
165165
if err != nil && kerrors.IsNotFound(err) {
166166
return err
167167
} else if err != nil {

app/artifact-cas/internal/service/download.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func (s *DownloadService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7676
return
7777
}
7878

79-
ctx, b, err := s.loadBackendForClaims(ctx, auth)
79+
b, err := s.loadBackend(ctx, auth.BackendType, auth.StoredSecretID)
8080
if err != nil && kerrors.IsNotFound(err) {
8181
http.Error(w, "backend not found", http.StatusNotFound)
8282
return

app/artifact-cas/internal/service/resource.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func (s *ResourceService) Describe(ctx context.Context, req *v1.ResourceServiceD
4848
return nil, err
4949
}
5050

51-
ctx, b, err := s.loadBackendForClaims(ctx, info)
51+
b, err := s.loadBackend(ctx, info.BackendType, info.StoredSecretID)
5252
if err != nil && errors.IsNotFound(err) {
5353
return nil, err
5454
} else if err != nil {

app/artifact-cas/internal/service/service.go

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323

2424
casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas"
2525
backend "github.com/chainloop-dev/chainloop/pkg/blobmanager"
26-
"github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint"
2726
"github.com/chainloop-dev/chainloop/pkg/servicelogger"
2827
kerrors "github.com/go-kratos/kratos/v2/errors"
2928
"github.com/go-kratos/kratos/v2/log"
@@ -50,11 +49,10 @@ func (s *commonService) loadBackend(ctx context.Context, providerType, secretID
5049

5150
s.log.Infow("msg", "selected provider", "provider", providerType)
5251

53-
// Retrieve the backend from where to download the file. The context
54-
// passed here is what the backend's STS-backed credentials provider
55-
// will see; the caller is responsible for having enriched it with
56-
// s3accesspoint.WithRequestingOrg when the request came in via an
57-
// authenticated CAS JWT. See loadBackendForClaims.
52+
// The requesting-org enrichment for managed CAS providers is done
53+
// at the auth boundary (see server.requestingOrgMiddleware /
54+
// requestingOrgHTTPMiddleware), so ctx already carries everything
55+
// the backend needs.
5856
backend, err := p.FromCredentials(ctx, secretID)
5957
if err != nil {
6058
return nil, fmt.Errorf("failed to retrieve backend: %w", err)
@@ -63,28 +61,6 @@ func (s *commonService) loadBackend(ctx context.Context, providerType, secretID
6361
return backend, nil
6462
}
6563

66-
// loadBackendForClaims is the request-scoped wrapper around loadBackend.
67-
// It pulls the requesting-org UUID out of the CAS JWT claims and stamps
68-
// it onto the context so providers that need per-tenant attribution
69-
// (currently AWS-S3-ACCESS-POINT) can mint correctly-scoped sessions.
70-
//
71-
// The OrgID claim is empty for legacy tokens minted before this PR; in
72-
// that case WithRequestingOrg sets an empty value and only managed
73-
// providers (which fail closed) will reject the request — every existing
74-
// provider ignores the key entirely, so non-managed flows stay unchanged.
75-
//
76-
// Returns the loaded backend together with the enriched context so the
77-
// caller can reuse it for the subsequent Upload/Download calls. Callers
78-
// MUST use the returned context, not the original one.
79-
func (s *commonService) loadBackendForClaims(ctx context.Context, claims *casJWT.Claims) (context.Context, backend.UploaderDownloader, error) {
80-
ctx = s3accesspoint.WithRequestingOrg(ctx, claims.OrgID)
81-
b, err := s.loadBackend(ctx, claims.BackendType, claims.StoredSecretID)
82-
if err != nil {
83-
return ctx, nil, err
84-
}
85-
return ctx, b, nil
86-
}
87-
8864
type NewOpt func(s *commonService)
8965

9066
func WithLogger(logger log.Logger) NewOpt {

app/controlplane/internal/service/attestation.go

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -494,13 +494,7 @@ func (s *AttestationService) GetUploadCreds(ctx context.Context, req *cpAPI.Atte
494494
// Return the backend information and associated credentials (if applicable)
495495
resp := &cpAPI.AttestationServiceGetUploadCredsResponse_Result{Backend: bizCASBackendToPb(backend)}
496496
if backend.SecretName != "" {
497-
// OrgID comes from the authenticated robot account (the caller),
498-
// not the backend's owner. Even though GetByIDInOrgOrPublic above
499-
// already scopes the lookup to robotAccount.OrgID, the security
500-
// invariant for managed CAS requires the JWT org-id claim to
501-
// originate from the caller's identity rather than the row we
502-
// happened to resolve.
503-
ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes, OrgID: robotAccount.OrgID}
497+
ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID}
504498
t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref)
505499
if err != nil {
506500
return nil, handleUseCaseErr(err, s.log)

app/controlplane/internal/service/cascredential.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,7 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS
149149
return nil, errors.BadRequest("invalid argument", "cannot upload or download artifacts from an inline CAS backend")
150150
}
151151

152-
// OrgID MUST come from the authenticated caller, not the resolved backend.
153-
// For managed CAS the JWT's org-id claim drives the AssumeRole session
154-
// name and AP-policy aws:userid match; pulling it from backend.OrganizationID
155-
// would short-circuit that check against the very row a tampered secret
156-
// could redirect.
157-
ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: currentOrg.ID}
152+
ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID}
158153
t, err := s.casUC.GenerateTemporaryCredentials(ref)
159154
if err != nil {
160155
return nil, handleUseCaseErr(err, s.log)

app/controlplane/internal/service/casredirect.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,7 @@ func (s *CASRedirectService) GetDownloadURL(ctx context.Context, req *pb.GetDown
126126

127127
// 2- add authentication token to the query params ?t=[token]
128128
if backend.SecretName != "" {
129-
// OrgID comes from the authenticated caller (currentOrg from
130-
// ctx), not the resolved backend. For managed CAS this is what
131-
// keys the AssumeRole session name and the AP-policy aws:userid
132-
// match; deriving it from backend.OrganizationID would weaken
133-
// the cross-tenant guarantee.
134-
ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes, OrgID: currentOrg.ID}
129+
ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID}
135130
t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref)
136131
if err != nil {
137132
return nil, handleUseCaseErr(err, s.log)

0 commit comments

Comments
 (0)