Skip to content

Commit 9853beb

Browse files
authored
feat(cas): download endpoint (#294)
Signed-off-by: Miguel Martinez Trivino <[email protected]>
1 parent a7bb2a5 commit 9853beb

File tree

13 files changed

+235
-46
lines changed

13 files changed

+235
-46
lines changed

app/artifact-cas/api/cas/v1/status_http.pb.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/artifact-cas/cmd/wire_gen.go

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/artifact-cas/configs/config.devel.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ credentials_service:
1919
token: ${VAULT_TOKEN:notasecret}
2020

2121
auth:
22-
robot_account_public_key_path: "../../devel/devkeys/cas.pub"
22+
public_key_path: "../../devel/devkeys/cas.pub"

app/artifact-cas/configs/samples/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Example config file
22
auth:
3-
robot_account_public_key_path: "./configs/devkeys/cas.public.pem"
3+
public_key_path: "./configs/devkeys/cas.public.pem"
44

55
server:
66
http:

app/artifact-cas/internal/conf/conf.pb.go

Lines changed: 25 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/artifact-cas/internal/conf/conf.proto

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,7 @@ message Server {
5959
message Auth {
6060
// Public key used to verify the received JWT token
6161
// This token in the context of chainloop has been crafted by the controlplane
62-
string robot_account_public_key_path = 1;
62+
string robot_account_public_key_path = 1 [deprecated = true];
63+
// Public key used to verify the received JWT token
64+
string public_key_path = 2;
6365
}

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

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,17 @@ import (
4444
// NewGRPCServer new a gRPC server.
4545
func NewGRPCServer(c *conf.Server, authConf *conf.Auth, byteService *service.ByteStreamService, rSvc *service.ResourceService, logger log.Logger) (*grpc.Server, error) {
4646
log := log.NewHelper(logger)
47-
log.Debugw("msg", "loading public key from file", "file", authConf.RobotAccountPublicKeyPath)
48-
4947
// Load the key on initialization instead of on every request
5048
// TODO: implement jwks endpoint
51-
rawKey, err := os.ReadFile(authConf.RobotAccountPublicKeyPath)
49+
publicKeyPath := authConf.GetPublicKeyPath()
50+
if publicKeyPath == "" {
51+
// Maintain backwards compatibility
52+
publicKeyPath = authConf.RobotAccountPublicKeyPath
53+
}
54+
55+
log.Debugw("msg", "loading public key from file", "file", publicKeyPath)
56+
57+
rawKey, err := os.ReadFile(publicKeyPath)
5258
if err != nil {
5359
return nil, fmt.Errorf("failed to load public key: %w", err)
5460
}
@@ -130,39 +136,49 @@ func jwtAuthFunc(keyFunc jwt.Keyfunc, signingMethod jwt.SigningMethod) grpc_auth
130136
return nil, err
131137
}
132138

133-
var tokenInfo *jwt.Token
134-
claims := &casJWT.Claims{}
135-
136-
tokenInfo, err = jwt.ParseWithClaims(token, claims, keyFunc)
139+
claims, err := verifyAndMarshalJWT(token, keyFunc, signingMethod)
137140
if err != nil {
138-
var ve *jwt.ValidationError
139-
if !errors.As(err, &ve) {
140-
return nil, errors.Unauthorized("UNAUTHORIZED", err.Error())
141-
}
142-
143-
if ve.Errors&jwt.ValidationErrorMalformed != 0 {
144-
return nil, jwtMiddleware.ErrTokenInvalid
145-
}
141+
return nil, err
142+
}
146143

147-
if ve.Errors&(jwt.ValidationErrorExpired) != 0 {
148-
return nil, jwtMiddleware.ErrTokenExpired
149-
}
144+
return jwtMiddleware.NewContext(ctx, claims), nil
145+
}
146+
}
150147

151-
if ve.Errors&(jwt.ValidationErrorNotValidYet) != 0 {
152-
return nil, jwtMiddleware.ErrTokenExpired
153-
}
148+
// verifyAndMarshalJWT verifies the token and returns the claims
149+
func verifyAndMarshalJWT(token string, keyFunc jwt.Keyfunc, signingMethod jwt.SigningMethod) (*casJWT.Claims, error) {
150+
var tokenInfo *jwt.Token
151+
claims := &casJWT.Claims{}
154152

155-
return nil, err
153+
tokenInfo, err := jwt.ParseWithClaims(token, claims, keyFunc)
154+
if err != nil {
155+
var ve *jwt.ValidationError
156+
if !errors.As(err, &ve) {
157+
return nil, errors.Unauthorized("UNAUTHORIZED", err.Error())
156158
}
157159

158-
if !tokenInfo.Valid {
160+
if ve.Errors&jwt.ValidationErrorMalformed != 0 {
159161
return nil, jwtMiddleware.ErrTokenInvalid
160162
}
161163

162-
if tokenInfo.Method != signingMethod {
163-
return nil, jwtMiddleware.ErrUnSupportSigningMethod
164+
if ve.Errors&(jwt.ValidationErrorExpired) != 0 {
165+
return nil, jwtMiddleware.ErrTokenExpired
164166
}
165167

166-
return jwtMiddleware.NewContext(ctx, tokenInfo.Claims), nil
168+
if ve.Errors&(jwt.ValidationErrorNotValidYet) != 0 {
169+
return nil, jwtMiddleware.ErrTokenExpired
170+
}
171+
172+
return nil, err
167173
}
174+
175+
if !tokenInfo.Valid {
176+
return nil, jwtMiddleware.ErrTokenInvalid
177+
}
178+
179+
if tokenInfo.Method != signingMethod {
180+
return nil, jwtMiddleware.ErrUnSupportSigningMethod
181+
}
182+
183+
return claims, nil
168184
}

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

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,26 @@
1616
package server
1717

1818
import (
19+
"fmt"
20+
"os"
21+
1922
"github.com/chainloop-dev/chainloop/app/artifact-cas/internal/conf"
2023
"github.com/chainloop-dev/chainloop/app/artifact-cas/internal/service"
24+
casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas"
25+
jwt "github.com/golang-jwt/jwt/v4"
26+
27+
nhttp "net/http"
2128

2229
api "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1"
2330
"github.com/go-kratos/kratos/v2/log"
31+
jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt"
2432
"github.com/go-kratos/kratos/v2/middleware/logging"
2533
"github.com/go-kratos/kratos/v2/middleware/recovery"
2634
"github.com/go-kratos/kratos/v2/transport/http"
2735
)
2836

2937
// NewHTTPServer new a HTTP server.
30-
func NewHTTPServer(c *conf.Server, logger log.Logger) *http.Server {
38+
func NewHTTPServer(c *conf.Server, authConf *conf.Auth, downloadSvc *service.DownloadService, logger log.Logger) (*http.Server, error) {
3139
var opts = []http.ServerOption{
3240
http.Middleware(
3341
recovery.Recovery(),
@@ -43,8 +51,47 @@ func NewHTTPServer(c *conf.Server, logger log.Logger) *http.Server {
4351
if c.Http.Timeout != nil {
4452
opts = append(opts, http.Timeout(c.Http.Timeout.AsDuration()))
4553
}
54+
55+
// Load the key on initialization instead of on every request
56+
// TODO: implement jwks endpoint
57+
publicKeyPath := authConf.GetPublicKeyPath()
58+
if publicKeyPath == "" {
59+
// Maintain backwards compatibility
60+
publicKeyPath = authConf.RobotAccountPublicKeyPath
61+
}
62+
63+
rawKey, err := os.ReadFile(publicKeyPath)
64+
if err != nil {
65+
return nil, fmt.Errorf("failed to load public key: %w", err)
66+
}
67+
4668
srv := http.NewServer(opts...)
4769

70+
srv.Handle(service.DownloadPath, authFromQueryMiddleware(loadPublicKey(rawKey), casJWT.SigningMethod, downloadSvc))
4871
api.RegisterStatusServiceHTTPServer(srv, service.NewStatusService(Version))
49-
return srv
72+
return srv, nil
73+
}
74+
75+
func authFromQueryMiddleware(keyFunc jwt.Keyfunc, signingMethod jwt.SigningMethod, next nhttp.Handler) nhttp.Handler {
76+
return nhttp.HandlerFunc(func(w http.ResponseWriter, r *nhttp.Request) {
77+
token := r.URL.Query().Get("t")
78+
if token == "" {
79+
nhttp.Error(w, "missing token", nhttp.StatusUnauthorized)
80+
return
81+
}
82+
83+
claims, err := verifyAndMarshalJWT(token, keyFunc, signingMethod)
84+
if err != nil {
85+
// return unauthorized
86+
nhttp.Error(w, "invalid token", nhttp.StatusUnauthorized)
87+
return
88+
}
89+
90+
// Attach the claims to the context
91+
ctx := jwtMiddleware.NewContext(r.Context(), claims)
92+
r = r.WithContext(ctx)
93+
94+
// Run the next handler
95+
next.ServeHTTP(w, r)
96+
})
5097
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//
2+
// Copyright 2023 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 service
17+
18+
import (
19+
"context"
20+
"errors"
21+
"fmt"
22+
"net/http"
23+
"strconv"
24+
25+
"code.cloudfoundry.org/bytefmt"
26+
backend "github.com/chainloop-dev/chainloop/internal/blobmanager"
27+
casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas"
28+
sl "github.com/chainloop-dev/chainloop/internal/servicelogger"
29+
cr_v1 "github.com/google/go-containerregistry/pkg/v1"
30+
"github.com/gorilla/mux"
31+
)
32+
33+
// i.e /download/sha256:1234567890abcdef
34+
const DownloadPath = "/download/{digest}"
35+
36+
type DownloadService struct {
37+
*commonService
38+
}
39+
40+
func NewDownloadService(bp backend.Provider, opts ...NewOpt) *DownloadService {
41+
return &DownloadService{
42+
commonService: newCommonService(bp, opts...),
43+
}
44+
}
45+
46+
func (s *DownloadService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
47+
ctx := r.Context()
48+
auth, err := infoFromAuth(ctx)
49+
if err != nil {
50+
http.Error(w, err.Error(), http.StatusUnauthorized)
51+
return
52+
}
53+
54+
digest, ok := mux.Vars(r)["digest"]
55+
if !ok {
56+
http.Error(w, "missing digest", http.StatusBadRequest)
57+
return
58+
}
59+
60+
hash, err := cr_v1.NewHash(digest)
61+
if err != nil {
62+
http.Error(w, "invalid digest", http.StatusBadRequest)
63+
return
64+
}
65+
66+
// Only downloader tokens are allowed
67+
if err := auth.CheckRole(casJWT.Downloader); err != nil {
68+
http.Error(w, err.Error(), http.StatusUnauthorized)
69+
return
70+
}
71+
72+
// Retrieve the CAS backend from where to download the file
73+
b, err := s.backendP.FromCredentials(ctx, auth.StoredSecretID)
74+
if err != nil {
75+
http.Error(w, sl.LogAndMaskErr(err, s.log).Error(), http.StatusInternalServerError)
76+
return
77+
}
78+
79+
info, err := b.Describe(ctx, hash.Hex)
80+
if err != nil && backend.IsNotFound(err) {
81+
http.Error(w, err.Error(), http.StatusNotFound)
82+
return
83+
} else if err != nil {
84+
http.Error(w, sl.LogAndMaskErr(err, s.log).Error(), http.StatusInternalServerError)
85+
return
86+
}
87+
88+
// Set headers
89+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", info.FileName))
90+
w.Header().Set("Content-Length", strconv.FormatInt(info.Size, 10))
91+
92+
s.log.Infow("msg", "download initialized", "digest", hash, "size", bytefmt.ByteSize(uint64(info.Size)))
93+
94+
if err := b.Download(ctx, w, hash.Hex); err != nil {
95+
if errors.Is(err, context.Canceled) {
96+
s.log.Infow("msg", "download canceled", "digest", hash)
97+
return
98+
}
99+
100+
http.Error(w, sl.LogAndMaskErr(err, s.log).Error(), http.StatusInternalServerError)
101+
}
102+
103+
s.log.Infow("msg", "download finished", "digest", hash, "size", bytefmt.ByteSize(uint64(info.Size)))
104+
}

0 commit comments

Comments
 (0)