Skip to content

Commit ca83017

Browse files
authored
feat: accept provisioner keys for provisioner auth (coder#13972)
1 parent d488853 commit ca83017

File tree

9 files changed

+351
-42
lines changed

9 files changed

+351
-42
lines changed

coderd/database/dbauthz/dbauthz.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ var (
245245
rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead},
246246
rbac.ResourceOrganizationMember.Type: {policy.ActionCreate},
247247
rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionUpdate},
248+
rbac.ResourceProvisionerKeys.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionDelete},
248249
rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(),
249250
rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop},
250251
rbac.ResourceWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionSSH},

coderd/httpmw/csrf.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ func CSRF(secureCookie bool) func(next http.Handler) http.Handler {
9393
return true
9494
}
9595

96+
if r.Header.Get(codersdk.ProvisionerDaemonKey) != "" {
97+
// If present, the provisioner daemon also is providing an api key
98+
// that will make them exempt from CSRF. But this is still useful
99+
// for enumerating the external auths.
100+
return true
101+
}
102+
96103
// If the X-CSRF-TOKEN header is set, we can exempt the func if it's valid.
97104
// This is the CSRF check.
98105
sent := r.Header.Get("X-CSRF-TOKEN")

coderd/httpmw/provisionerdaemon.go

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/coder/coder/v2/coderd/database"
99
"github.com/coder/coder/v2/coderd/database/dbauthz"
1010
"github.com/coder/coder/v2/coderd/httpapi"
11+
"github.com/coder/coder/v2/coderd/provisionerkey"
1112
"github.com/coder/coder/v2/codersdk"
1213
)
1314

@@ -19,11 +20,13 @@ func ProvisionerDaemonAuthenticated(r *http.Request) bool {
1920
}
2021

2122
type ExtractProvisionerAuthConfig struct {
22-
DB database.Store
23-
Optional bool
23+
DB database.Store
24+
Optional bool
25+
PSK string
26+
MultiOrgEnabled bool
2427
}
2528

26-
func ExtractProvisionerDaemonAuthenticated(opts ExtractProvisionerAuthConfig, psk string) func(next http.Handler) http.Handler {
29+
func ExtractProvisionerDaemonAuthenticated(opts ExtractProvisionerAuthConfig) func(next http.Handler) http.Handler {
2730
return func(next http.Handler) http.Handler {
2831
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2932
ctx := r.Context()
@@ -36,37 +39,103 @@ func ExtractProvisionerDaemonAuthenticated(opts ExtractProvisionerAuthConfig, ps
3639
httpapi.Write(ctx, w, code, response)
3740
}
3841

39-
if psk == "" {
40-
// No psk means external provisioner daemons are not allowed.
41-
// So their auth is not valid.
42+
if !opts.MultiOrgEnabled {
43+
if opts.PSK == "" {
44+
handleOptional(http.StatusUnauthorized, codersdk.Response{
45+
Message: "External provisioner daemons not enabled",
46+
})
47+
return
48+
}
49+
50+
fallbackToPSK(ctx, opts.PSK, next, w, r, handleOptional)
51+
return
52+
}
53+
54+
psk := r.Header.Get(codersdk.ProvisionerDaemonPSK)
55+
key := r.Header.Get(codersdk.ProvisionerDaemonKey)
56+
if key == "" {
57+
if opts.PSK == "" {
58+
handleOptional(http.StatusUnauthorized, codersdk.Response{
59+
Message: "provisioner daemon key required",
60+
})
61+
return
62+
}
63+
64+
fallbackToPSK(ctx, opts.PSK, next, w, r, handleOptional)
65+
return
66+
}
67+
if psk != "" {
4268
handleOptional(http.StatusBadRequest, codersdk.Response{
43-
Message: "External provisioner daemons not enabled",
69+
Message: "provisioner daemon key and psk provided, but only one is allowed",
4470
})
4571
return
4672
}
4773

48-
token := r.Header.Get(codersdk.ProvisionerDaemonPSK)
49-
if token == "" {
74+
id, keyValue, err := provisionerkey.Parse(key)
75+
if err != nil {
5076
handleOptional(http.StatusUnauthorized, codersdk.Response{
51-
Message: "provisioner daemon auth token required",
77+
Message: "provisioner daemon key invalid",
78+
})
79+
return
80+
}
81+
82+
// nolint:gocritic // System must check if the provisioner key is valid.
83+
pk, err := opts.DB.GetProvisionerKeyByID(dbauthz.AsSystemRestricted(ctx), id)
84+
if err != nil {
85+
if httpapi.Is404Error(err) {
86+
handleOptional(http.StatusUnauthorized, codersdk.Response{
87+
Message: "provisioner daemon key invalid",
88+
})
89+
return
90+
}
91+
92+
handleOptional(http.StatusInternalServerError, codersdk.Response{
93+
Message: "get provisioner daemon key: " + err.Error(),
5294
})
5395
return
5496
}
5597

56-
if subtle.ConstantTimeCompare([]byte(token), []byte(psk)) != 1 {
98+
if provisionerkey.Compare(pk.HashedSecret, provisionerkey.HashSecret(keyValue)) {
5799
handleOptional(http.StatusUnauthorized, codersdk.Response{
58-
Message: "provisioner daemon auth token invalid",
100+
Message: "provisioner daemon key invalid",
59101
})
60102
return
61103
}
62104

63-
// The PSK does not indicate a specific provisioner daemon. So just
105+
// The provisioner key does not indicate a specific provisioner daemon. So just
64106
// store a boolean so the caller can check if the request is from an
65107
// authenticated provisioner daemon.
66108
ctx = context.WithValue(ctx, provisionerDaemonContextKey{}, true)
109+
// store key used to authenticate the request
110+
ctx = context.WithValue(ctx, provisionerKeyAuthContextKey{}, pk)
67111
// nolint:gocritic // Authenticating as a provisioner daemon.
68112
ctx = dbauthz.AsProvisionerd(ctx)
69113
next.ServeHTTP(w, r.WithContext(ctx))
70114
})
71115
}
72116
}
117+
118+
type provisionerKeyAuthContextKey struct{}
119+
120+
func ProvisionerKeyAuthOptional(r *http.Request) (database.ProvisionerKey, bool) {
121+
user, ok := r.Context().Value(provisionerKeyAuthContextKey{}).(database.ProvisionerKey)
122+
return user, ok
123+
}
124+
125+
func fallbackToPSK(ctx context.Context, psk string, next http.Handler, w http.ResponseWriter, r *http.Request, handleOptional func(code int, response codersdk.Response)) {
126+
token := r.Header.Get(codersdk.ProvisionerDaemonPSK)
127+
if subtle.ConstantTimeCompare([]byte(token), []byte(psk)) != 1 {
128+
handleOptional(http.StatusUnauthorized, codersdk.Response{
129+
Message: "provisioner daemon psk invalid",
130+
})
131+
return
132+
}
133+
134+
// The PSK does not indicate a specific provisioner daemon. So just
135+
// store a boolean so the caller can check if the request is from an
136+
// authenticated provisioner daemon.
137+
ctx = context.WithValue(ctx, provisionerDaemonContextKey{}, true)
138+
// nolint:gocritic // Authenticating as a provisioner daemon.
139+
ctx = dbauthz.AsProvisionerd(ctx)
140+
next.ServeHTTP(w, r.WithContext(ctx))
141+
}

coderd/provisionerkey/provisionerkey.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package provisionerkey
22

33
import (
44
"crypto/sha256"
5+
"crypto/subtle"
56
"fmt"
7+
"strings"
68

79
"github.com/google/uuid"
810
"golang.org/x/xerrors"
@@ -18,14 +20,37 @@ func New(organizationID uuid.UUID, name string) (database.InsertProvisionerKeyPa
1820
if err != nil {
1921
return database.InsertProvisionerKeyParams{}, "", xerrors.Errorf("generate token: %w", err)
2022
}
21-
hashedSecret := sha256.Sum256([]byte(secret))
23+
hashedSecret := HashSecret(secret)
2224
token := fmt.Sprintf("%s:%s", id, secret)
2325

2426
return database.InsertProvisionerKeyParams{
2527
ID: id,
2628
CreatedAt: dbtime.Now(),
2729
OrganizationID: organizationID,
2830
Name: name,
29-
HashedSecret: hashedSecret[:],
31+
HashedSecret: hashedSecret,
3032
}, token, nil
3133
}
34+
35+
func Parse(token string) (uuid.UUID, string, error) {
36+
parts := strings.Split(token, ":")
37+
if len(parts) != 2 {
38+
return uuid.UUID{}, "", xerrors.Errorf("invalid token format")
39+
}
40+
41+
id, err := uuid.Parse(parts[0])
42+
if err != nil {
43+
return uuid.UUID{}, "", xerrors.Errorf("parse id: %w", err)
44+
}
45+
46+
return id, parts[1], nil
47+
}
48+
49+
func HashSecret(secret string) []byte {
50+
h := sha256.Sum256([]byte(secret))
51+
return h[:]
52+
}
53+
54+
func Compare(a []byte, b []byte) bool {
55+
return subtle.ConstantTimeCompare(a, b) != 1
56+
}

codersdk/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ const (
7979
// ProvisionerDaemonPSK contains the authentication pre-shared key for an external provisioner daemon
8080
ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK"
8181

82+
// ProvisionerDaemonKey contains the authentication key for an external provisioner daemon
83+
ProvisionerDaemonKey = "Coder-Provisioner-Daemon-Key"
84+
8285
// BuildVersionHeader contains build information of Coder.
8386
BuildVersionHeader = "X-Coder-Build-Version"
8487

codersdk/provisionerdaemons.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ type ServeProvisionerDaemonRequest struct {
189189
Tags map[string]string `json:"tags"`
190190
// PreSharedKey is an authentication key to use on the API instead of the normal session token from the client.
191191
PreSharedKey string `json:"pre_shared_key"`
192+
// ProvisionerKey is an authentication key to use on the API instead of the normal session token from the client.
193+
ProvisionerKey string `json:"provisioner_key"`
192194
}
193195

194196
// ServeProvisionerDaemon returns the gRPC service for a provisioner daemon
@@ -223,8 +225,15 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione
223225
headers := http.Header{}
224226

225227
headers.Set(BuildVersionHeader, buildinfo.Version())
226-
if req.PreSharedKey == "" {
227-
// use session token if we don't have a PSK.
228+
229+
if req.ProvisionerKey != "" {
230+
headers.Set(ProvisionerDaemonKey, req.ProvisionerKey)
231+
}
232+
if req.PreSharedKey != "" {
233+
headers.Set(ProvisionerDaemonPSK, req.PreSharedKey)
234+
}
235+
if req.ProvisionerKey == "" && req.PreSharedKey == "" {
236+
// use session token if we don't have a PSK or provisioner key.
228237
jar, err := cookiejar.New(nil)
229238
if err != nil {
230239
return nil, xerrors.Errorf("create cookie jar: %w", err)
@@ -234,8 +243,6 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione
234243
Value: c.SessionToken(),
235244
}})
236245
httpClient.Jar = jar
237-
} else {
238-
headers.Set(ProvisionerDaemonPSK, req.PreSharedKey)
239246
}
240247

241248
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{

enterprise/coderd/coderd.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
110110
provisionerDaemonAuth: &provisionerDaemonAuth{
111111
psk: options.ProvisionerDaemonPSK,
112112
authorizer: options.Authorizer,
113+
db: options.Database,
113114
},
114115
}
115116
// This must happen before coderd initialization!
@@ -285,9 +286,11 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
285286
api.provisionerDaemonsEnabledMW,
286287
apiKeyMiddlewareOptional,
287288
httpmw.ExtractProvisionerDaemonAuthenticated(httpmw.ExtractProvisionerAuthConfig{
288-
DB: api.Database,
289-
Optional: true,
290-
}, api.ProvisionerDaemonPSK),
289+
DB: api.Database,
290+
Optional: true,
291+
PSK: api.ProvisionerDaemonPSK,
292+
MultiOrgEnabled: api.AGPL.Experiments.Enabled(codersdk.ExperimentMultiOrganization),
293+
}),
291294
// Either a user auth or provisioner auth is required
292295
// to move forward.
293296
httpmw.RequireAPIKeyOrProvisionerDaemonAuth(),

enterprise/coderd/provisionerdaemons.go

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -79,36 +79,58 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
7979

8080
type provisionerDaemonAuth struct {
8181
psk string
82+
db database.Store
8283
authorizer rbac.Authorizer
8384
}
8485

85-
// authorize returns mutated tags and true if the given HTTP request is authorized to access the provisioner daemon
86-
// protobuf API, and returns nil, false otherwise.
87-
func (p *provisionerDaemonAuth) authorize(r *http.Request, orgID uuid.UUID, tags map[string]string) (map[string]string, bool) {
86+
// authorize returns mutated tags if the given HTTP request is authorized to access the provisioner daemon
87+
// protobuf API, and returns nil, err otherwise.
88+
func (p *provisionerDaemonAuth) authorize(r *http.Request, orgID uuid.UUID, tags map[string]string) (map[string]string, error) {
8889
ctx := r.Context()
89-
apiKey, ok := httpmw.APIKeyOptional(r)
90-
if ok {
90+
apiKey, apiKeyOK := httpmw.APIKeyOptional(r)
91+
pk, pkOK := httpmw.ProvisionerKeyAuthOptional(r)
92+
provAuth := httpmw.ProvisionerDaemonAuthenticated(r)
93+
if !provAuth && !apiKeyOK {
94+
return nil, xerrors.New("no API key or provisioner key provided")
95+
}
96+
if apiKeyOK && pkOK {
97+
return nil, xerrors.New("Both API key and provisioner key authentication provided. Only one is allowed.")
98+
}
99+
100+
if apiKeyOK {
91101
tags = provisionersdk.MutateTags(apiKey.UserID, tags)
92102
if tags[provisionersdk.TagScope] == provisionersdk.ScopeUser {
93103
// Any authenticated user can create provisioner daemons scoped
94104
// for jobs that they own,
95-
return tags, true
105+
return tags, nil
96106
}
97107
ua := httpmw.UserAuthorization(r)
98-
if err := p.authorizer.Authorize(ctx, ua, policy.ActionCreate, rbac.ResourceProvisionerDaemon.InOrg(orgID)); err == nil {
99-
// User is allowed to create provisioner daemons
100-
return tags, true
108+
err := p.authorizer.Authorize(ctx, ua, policy.ActionCreate, rbac.ResourceProvisionerDaemon.InOrg(orgID))
109+
if err != nil {
110+
if !provAuth {
111+
return nil, xerrors.New("user unauthorized")
112+
}
113+
114+
// Allow fallback to PSK auth if the user is not allowed to create provisioner daemons.
115+
// This is to preserve backwards compatibility with existing user provisioner daemons.
116+
// If using PSK auth, the daemon is, by definition, scoped to the organization.
117+
tags = provisionersdk.MutateTags(uuid.Nil, tags)
118+
return tags, nil
101119
}
120+
121+
// User is allowed to create provisioner daemons
122+
return tags, nil
102123
}
103124

104-
// Check for PSK
105-
provAuth := httpmw.ProvisionerDaemonAuthenticated(r)
106-
if provAuth {
107-
// If using PSK auth, the daemon is, by definition, scoped to the organization.
108-
tags = provisionersdk.MutateTags(uuid.Nil, tags)
109-
return tags, true
125+
if pkOK {
126+
if pk.OrganizationID != orgID {
127+
return nil, xerrors.New("provisioner key unauthorized")
128+
}
110129
}
111-
return nil, false
130+
131+
// If using provisioner key / PSK auth, the daemon is, by definition, scoped to the organization.
132+
tags = provisionersdk.MutateTags(uuid.Nil, tags)
133+
return tags, nil
112134
}
113135

114136
// Serves the provisioner daemon protobuf API over a WebSocket.
@@ -171,12 +193,13 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
171193
api.Logger.Warn(ctx, "unnamed provisioner daemon")
172194
}
173195

174-
tags, authorized := api.provisionerDaemonAuth.authorize(r, organization.ID, tags)
175-
if !authorized {
176-
api.Logger.Warn(ctx, "unauthorized provisioner daemon serve request", slog.F("tags", tags))
196+
tags, err := api.provisionerDaemonAuth.authorize(r, organization.ID, tags)
197+
if err != nil {
198+
api.Logger.Warn(ctx, "unauthorized provisioner daemon serve request", slog.F("tags", tags), slog.Error(err))
177199
httpapi.Write(ctx, rw, http.StatusForbidden,
178200
codersdk.Response{
179201
Message: fmt.Sprintf("You aren't allowed to create provisioner daemons with scope %q", tags[provisionersdk.TagScope]),
202+
Detail: err.Error(),
180203
},
181204
)
182205
return
@@ -209,7 +232,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
209232
)
210233

211234
authCtx := ctx
212-
if r.Header.Get(codersdk.ProvisionerDaemonPSK) != "" {
235+
if r.Header.Get(codersdk.ProvisionerDaemonPSK) != "" || r.Header.Get(codersdk.ProvisionerDaemonKey) != "" {
213236
//nolint:gocritic // PSK auth means no actor in request,
214237
// so use system restricted.
215238
authCtx = dbauthz.AsSystemRestricted(ctx)

0 commit comments

Comments
 (0)