Skip to content

Commit 27c18ed

Browse files
committed
feat: accept provisioner keys for provisioner auth
1 parent 0a71c34 commit 27c18ed

File tree

8 files changed

+177
-59
lines changed

8 files changed

+177
-59
lines changed

coderd/database/dbauthz/dbauthz.go

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -158,34 +158,49 @@ func ActorFromContext(ctx context.Context) (rbac.Subject, bool) {
158158
}
159159

160160
var (
161-
subjectProvisionerd = rbac.Subject{
162-
FriendlyName: "Provisioner Daemon",
163-
ID: uuid.Nil.String(),
164-
Roles: rbac.Roles([]rbac.Role{
165-
{
166-
Identifier: rbac.RoleIdentifier{Name: "provisionerd"},
167-
DisplayName: "Provisioner Daemon",
168-
Site: rbac.Permissions(map[string][]policy.Action{
169-
// TODO: Add ProvisionerJob resource type.
170-
rbac.ResourceFile.Type: {policy.ActionRead},
171-
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
172-
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
173-
// Unsure why provisionerd needs update and read personal
174-
rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
175-
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
176-
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop},
177-
rbac.ResourceApiKey.Type: {policy.WildcardSymbol},
178-
// When org scoped provisioner credentials are implemented,
179-
// this can be reduced to read a specific org.
161+
subjectProvisionerd = func(orgID uuid.UUID) rbac.Subject {
162+
sitePermissions := map[string][]policy.Action{
163+
// TODO: Add ProvisionerJob resource type.
164+
rbac.ResourceFile.Type: {policy.ActionRead},
165+
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
166+
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
167+
// Unsure why provisionerd needs update and read personal
168+
rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
169+
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
170+
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop},
171+
rbac.ResourceApiKey.Type: {policy.WildcardSymbol},
172+
// When org scoped provisioner credentials are implemented,
173+
// this can be reduced to read a specific org.
174+
rbac.ResourceOrganization.Type: {policy.ActionRead},
175+
rbac.ResourceGroup.Type: {policy.ActionRead},
176+
}
177+
orgPermissions := map[string][]rbac.Permission{}
178+
179+
if orgID != uuid.Nil {
180+
// replace site wide org permissions with org scoped permissions
181+
delete(sitePermissions, rbac.ResourceOrganization.Type)
182+
orgPermissions = map[string][]rbac.Permission{
183+
orgID.String(): rbac.Permissions(map[string][]policy.Action{
180184
rbac.ResourceOrganization.Type: {policy.ActionRead},
181-
rbac.ResourceGroup.Type: {policy.ActionRead},
182185
}),
183-
Org: map[string][]rbac.Permission{},
184-
User: []rbac.Permission{},
185-
},
186-
}),
187-
Scope: rbac.ScopeAll,
188-
}.WithCachedASTValue()
186+
}
187+
}
188+
189+
return rbac.Subject{
190+
FriendlyName: "Provisioner Daemon",
191+
ID: uuid.Nil.String(),
192+
Roles: rbac.Roles([]rbac.Role{
193+
{
194+
Identifier: rbac.RoleIdentifier{Name: "provisionerd"},
195+
DisplayName: "Provisioner Daemon",
196+
Site: rbac.Permissions(sitePermissions),
197+
Org: orgPermissions,
198+
User: []rbac.Permission{},
199+
},
200+
}),
201+
Scope: rbac.ScopeAll,
202+
}.WithCachedASTValue()
203+
}
189204

190205
subjectAutostart = rbac.Subject{
191206
FriendlyName: "Autostart",
@@ -261,7 +276,13 @@ var (
261276
// AsProvisionerd returns a context with an actor that has permissions required
262277
// for provisionerd to function.
263278
func AsProvisionerd(ctx context.Context) context.Context {
264-
return context.WithValue(ctx, authContextKey{}, subjectProvisionerd)
279+
return context.WithValue(ctx, authContextKey{}, subjectProvisionerd(uuid.Nil))
280+
}
281+
282+
// AsProvisionerd returns a context with an actor that has permissions required
283+
// for an org scoped provisionerd to function.
284+
func AsOrganizationProvisionerd(ctx context.Context, orgID uuid.UUID) context.Context {
285+
return context.WithValue(ctx, authContextKey{}, subjectProvisionerd(orgID))
265286
}
266287

267288
// AsAutostart returns a context with an actor that has permissions required

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: 67 additions & 15 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,26 +39,50 @@ 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-
handleOptional(http.StatusBadRequest, codersdk.Response{
43-
Message: "External provisioner daemons not enabled",
44-
})
42+
if !opts.MultiOrgEnabled {
43+
fallbackToPSK(ctx, opts.PSK, next, w, r, handleOptional)
4544
return
4645
}
4746

48-
token := r.Header.Get(codersdk.ProvisionerDaemonPSK)
49-
if token == "" {
47+
key := r.Header.Get(codersdk.ProvisionerDaemonKey)
48+
if key == "" {
49+
if opts.PSK == "" {
50+
handleOptional(http.StatusUnauthorized, codersdk.Response{
51+
Message: "provisioner daemon key required",
52+
})
53+
return
54+
}
55+
56+
fallbackToPSK(ctx, opts.PSK, next, w, r, handleOptional)
57+
return
58+
}
59+
60+
id, keyValue, err := provisionerkey.Parse(key)
61+
if err != nil {
5062
handleOptional(http.StatusUnauthorized, codersdk.Response{
51-
Message: "provisioner daemon auth token required",
63+
Message: "provisioner daemon key invalid",
64+
})
65+
return
66+
}
67+
68+
pk, err := opts.DB.GetProvisionerKeyByID(ctx, id)
69+
if err != nil {
70+
if httpapi.Is404Error(err) {
71+
handleOptional(http.StatusUnauthorized, codersdk.Response{
72+
Message: "provisioner daemon key invalid",
73+
})
74+
return
75+
}
76+
77+
handleOptional(http.StatusInternalServerError, codersdk.Response{
78+
Message: "get provisioner daemon key: " + err.Error(),
5279
})
5380
return
5481
}
5582

56-
if subtle.ConstantTimeCompare([]byte(token), []byte(psk)) != 1 {
83+
if subtle.ConstantTimeCompare(pk.HashedSecret, provisionerkey.HashSecret(keyValue)) != 1 {
5784
handleOptional(http.StatusUnauthorized, codersdk.Response{
58-
Message: "provisioner daemon auth token invalid",
85+
Message: "provisioner daemon key invalid",
5986
})
6087
return
6188
}
@@ -65,8 +92,33 @@ func ExtractProvisionerDaemonAuthenticated(opts ExtractProvisionerAuthConfig, ps
6592
// authenticated provisioner daemon.
6693
ctx = context.WithValue(ctx, provisionerDaemonContextKey{}, true)
6794
// nolint:gocritic // Authenticating as a provisioner daemon.
68-
ctx = dbauthz.AsProvisionerd(ctx)
95+
ctx = dbauthz.AsOrganizationProvisionerd(ctx, pk.OrganizationID)
6996
next.ServeHTTP(w, r.WithContext(ctx))
7097
})
7198
}
7299
}
100+
101+
func fallbackToPSK(ctx context.Context, psk string, next http.Handler, w http.ResponseWriter, r *http.Request, handleOptional func(code int, response codersdk.Response)) {
102+
if psk == "" {
103+
handleOptional(http.StatusUnauthorized, codersdk.Response{
104+
Message: "External provisioner daemons not enabled",
105+
})
106+
return
107+
}
108+
109+
token := r.Header.Get(codersdk.ProvisionerDaemonPSK)
110+
if subtle.ConstantTimeCompare([]byte(token), []byte(psk)) != 1 {
111+
handleOptional(http.StatusUnauthorized, codersdk.Response{
112+
Message: "provisioner daemon psk invalid",
113+
})
114+
return
115+
}
116+
117+
// The PSK does not indicate a specific provisioner daemon. So just
118+
// store a boolean so the caller can check if the request is from an
119+
// authenticated provisioner daemon.
120+
ctx = context.WithValue(ctx, provisionerDaemonContextKey{}, true)
121+
// nolint:gocritic // Authenticating as a provisioner daemon.
122+
ctx = dbauthz.AsProvisionerd(ctx)
123+
next.ServeHTTP(w, r.WithContext(ctx))
124+
}

coderd/provisionerkey/provisionerkey.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package provisionerkey
33
import (
44
"crypto/sha256"
55
"fmt"
6+
"strings"
67

78
"github.com/google/uuid"
89
"golang.org/x/xerrors"
@@ -18,14 +19,33 @@ func New(organizationID uuid.UUID, name string) (database.InsertProvisionerKeyPa
1819
if err != nil {
1920
return database.InsertProvisionerKeyParams{}, "", xerrors.Errorf("generate token: %w", err)
2021
}
21-
hashedSecret := sha256.Sum256([]byte(secret))
22+
hashedSecret := HashSecret(secret)
2223
token := fmt.Sprintf("%s:%s", id, secret)
2324

2425
return database.InsertProvisionerKeyParams{
2526
ID: id,
2627
CreatedAt: dbtime.Now(),
2728
OrganizationID: organizationID,
2829
Name: name,
29-
HashedSecret: hashedSecret[:],
30+
HashedSecret: hashedSecret,
3031
}, token, nil
3132
}
33+
34+
func Parse(token string) (uuid.UUID, string, error) {
35+
parts := strings.Split(token, ":")
36+
if len(parts) != 2 {
37+
return uuid.UUID{}, "", xerrors.Errorf("invalid token format")
38+
}
39+
40+
id, err := uuid.Parse(parts[0])
41+
if err != nil {
42+
return uuid.UUID{}, "", xerrors.Errorf("parse id: %w", err)
43+
}
44+
45+
return id, parts[1], nil
46+
}
47+
48+
func HashSecret(secret string) []byte {
49+
h := sha256.Sum256([]byte(secret))
50+
return h[:]
51+
}

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: 8 additions & 3 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,7 +225,12 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione
223225
headers := http.Header{}
224226

225227
headers.Set(BuildVersionHeader, buildinfo.Version())
226-
if req.PreSharedKey == "" {
228+
// nolint:gocritic // Need to support multiple exclusive auth flows.
229+
if req.ProvisionerKey != "" {
230+
headers.Set(ProvisionerDaemonKey, req.ProvisionerKey)
231+
} else if req.PreSharedKey != "" {
232+
headers.Set(ProvisionerDaemonPSK, req.PreSharedKey)
233+
} else {
227234
// use session token if we don't have a PSK.
228235
jar, err := cookiejar.New(nil)
229236
if err != nil {
@@ -234,8 +241,6 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione
234241
Value: c.SessionToken(),
235242
}})
236243
httpClient.Jar = jar
237-
} else {
238-
headers.Set(ProvisionerDaemonPSK, req.PreSharedKey)
239244
}
240245

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

enterprise/coderd/coderd.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,11 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
284284
api.provisionerDaemonsEnabledMW,
285285
apiKeyMiddlewareOptional,
286286
httpmw.ExtractProvisionerDaemonAuthenticated(httpmw.ExtractProvisionerAuthConfig{
287-
DB: api.Database,
288-
Optional: true,
289-
}, api.ProvisionerDaemonPSK),
287+
DB: api.Database,
288+
Optional: true,
289+
PSK: api.ProvisionerDaemonPSK,
290+
MultiOrgEnabled: api.AGPL.Experiments.Enabled(codersdk.ExperimentMultiOrganization),
291+
}),
290292
// Either a user auth or provisioner auth is required
291293
// to move forward.
292294
httpmw.RequireAPIKeyOrProvisionerDaemonAuth(),

enterprise/coderd/provisionerdaemons.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,13 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
9393

9494
type provisionerDaemonAuth struct {
9595
psk string
96+
db database.Store
9697
authorizer rbac.Authorizer
9798
}
9899

99100
// authorize returns mutated tags and true if the given HTTP request is authorized to access the provisioner daemon
100101
// protobuf API, and returns nil, false otherwise.
101-
func (p *provisionerDaemonAuth) authorize(r *http.Request, tags map[string]string) (map[string]string, bool) {
102+
func (p *provisionerDaemonAuth) authorize(r *http.Request, orgID uuid.UUID, tags map[string]string) (map[string]string, bool) {
102103
ctx := r.Context()
103104
apiKey, ok := httpmw.APIKeyOptional(r)
104105
if ok {
@@ -115,14 +116,21 @@ func (p *provisionerDaemonAuth) authorize(r *http.Request, tags map[string]strin
115116
}
116117
}
117118

118-
// Check for PSK
119+
// Check for provisioner key or PSK auth.
119120
provAuth := httpmw.ProvisionerDaemonAuthenticated(r)
120-
if provAuth {
121-
// If using PSK auth, the daemon is, by definition, scoped to the organization.
122-
tags = provisionersdk.MutateTags(uuid.Nil, tags)
123-
return tags, true
121+
if !provAuth {
122+
return nil, false
124123
}
125-
return nil, false
124+
125+
// ensure provisioner daemon subject can read organization
126+
_, err := p.db.GetOrganizationByID(ctx, orgID)
127+
if err != nil {
128+
return nil, false
129+
}
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, true
126134
}
127135

128136
// Serves the provisioner daemon protobuf API over a WebSocket.
@@ -185,7 +193,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
185193
api.Logger.Warn(ctx, "unnamed provisioner daemon")
186194
}
187195

188-
tags, authorized := api.provisionerDaemonAuth.authorize(r, tags)
196+
tags, authorized := api.provisionerDaemonAuth.authorize(r, organization.ID, tags)
189197
if !authorized {
190198
api.Logger.Warn(ctx, "unauthorized provisioner daemon serve request", slog.F("tags", tags))
191199
httpapi.Write(ctx, rw, http.StatusForbidden,
@@ -223,7 +231,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
223231
)
224232

225233
authCtx := ctx
226-
if r.Header.Get(codersdk.ProvisionerDaemonPSK) != "" {
234+
if r.Header.Get(codersdk.ProvisionerDaemonPSK) != "" || r.Header.Get(codersdk.ProvisionerDaemonKey) != "" {
227235
//nolint:gocritic // PSK auth means no actor in request,
228236
// so use system restricted.
229237
authCtx = dbauthz.AsSystemRestricted(ctx)

0 commit comments

Comments
 (0)