Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 1bdd2ab

Browse files
authored
feat: use JWT ticket to avoid DB queries on apps (#6148)
Issue a JWT ticket on the first request with a short expiry that contains details about which workspace/agent/app combo the ticket is valid for.
1 parent f8494d2 commit 1bdd2ab

37 files changed

+2736
-896
lines changed

cli/server.go

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"crypto/tls"
1111
"crypto/x509"
1212
"database/sql"
13+
"encoding/hex"
1314
"errors"
1415
"fmt"
1516
"io"
@@ -587,19 +588,62 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
587588
defer options.Pubsub.Close()
588589
}
589590

590-
deploymentID, err := options.Database.GetDeploymentID(ctx)
591-
if errors.Is(err, sql.ErrNoRows) {
592-
err = nil
593-
}
594-
if err != nil {
595-
return xerrors.Errorf("get deployment id: %w", err)
596-
}
597-
if deploymentID == "" {
598-
deploymentID = uuid.NewString()
599-
err = options.Database.InsertDeploymentID(ctx, deploymentID)
591+
var deploymentID string
592+
err = options.Database.InTx(func(tx database.Store) error {
593+
// This will block until the lock is acquired, and will be
594+
// automatically released when the transaction ends.
595+
err := tx.AcquireLock(ctx, database.LockIDDeploymentSetup)
596+
if err != nil {
597+
return xerrors.Errorf("acquire lock: %w", err)
598+
}
599+
600+
deploymentID, err = tx.GetDeploymentID(ctx)
601+
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
602+
return xerrors.Errorf("get deployment id: %w", err)
603+
}
604+
if deploymentID == "" {
605+
deploymentID = uuid.NewString()
606+
err = tx.InsertDeploymentID(ctx, deploymentID)
607+
if err != nil {
608+
return xerrors.Errorf("set deployment id: %w", err)
609+
}
610+
}
611+
612+
// Read the app signing key from the DB. We store it hex
613+
// encoded since the config table uses strings for the value and
614+
// we don't want to deal with automatic encoding issues.
615+
appSigningKeyStr, err := tx.GetAppSigningKey(ctx)
616+
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
617+
return xerrors.Errorf("get app signing key: %w", err)
618+
}
619+
if appSigningKeyStr == "" {
620+
// Generate 64 byte secure random string.
621+
b := make([]byte, 64)
622+
_, err := rand.Read(b)
623+
if err != nil {
624+
return xerrors.Errorf("generate fresh app signing key: %w", err)
625+
}
626+
627+
appSigningKeyStr = hex.EncodeToString(b)
628+
err = tx.InsertAppSigningKey(ctx, appSigningKeyStr)
629+
if err != nil {
630+
return xerrors.Errorf("insert freshly generated app signing key to database: %w", err)
631+
}
632+
}
633+
634+
appSigningKey, err := hex.DecodeString(appSigningKeyStr)
600635
if err != nil {
601-
return xerrors.Errorf("set deployment id: %w", err)
636+
return xerrors.Errorf("decode app signing key from database as hex: %w", err)
637+
}
638+
if len(appSigningKey) != 64 {
639+
return xerrors.Errorf("app signing key must be 64 bytes, key in database is %d bytes", len(appSigningKey))
602640
}
641+
642+
options.AppSigningKey = appSigningKey
643+
return nil
644+
}, nil)
645+
if err != nil {
646+
return err
603647
}
604648

605649
// Disable telemetry if the in-memory database is used unless explicitly defined!

coderd/coderd.go

Lines changed: 32 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import (
5656
"github.com/coder/coder/coderd/tracing"
5757
"github.com/coder/coder/coderd/updatecheck"
5858
"github.com/coder/coder/coderd/util/slice"
59+
"github.com/coder/coder/coderd/workspaceapps"
5960
"github.com/coder/coder/coderd/wsconncache"
6061
"github.com/coder/coder/codersdk"
6162
"github.com/coder/coder/provisionerd/proto"
@@ -120,6 +121,9 @@ type Options struct {
120121
SwaggerEndpoint bool
121122
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
122123
TemplateScheduleStore schedule.TemplateScheduleStore
124+
// AppSigningKey denotes the symmetric key to use for signing app tickets.
125+
// The key must be 64 bytes long.
126+
AppSigningKey []byte
123127

124128
// APIRateLimit is the minutely throughput rate limit per user or ip.
125129
// Setting a rate limit <0 will disable the rate limiter across the entire
@@ -214,6 +218,9 @@ func New(options *Options) *API {
214218
if options.TemplateScheduleStore == nil {
215219
options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore()
216220
}
221+
if len(options.AppSigningKey) != 64 {
222+
panic("coderd: AppSigningKey must be 64 bytes long")
223+
}
217224

218225
siteCacheDir := options.CacheDir
219226
if siteCacheDir != "" {
@@ -236,6 +243,11 @@ func New(options *Options) *API {
236243
// static files since it only affects browsers.
237244
staticHandler = httpmw.HSTS(staticHandler, options.StrictTransportSecurityCfg)
238245

246+
oauthConfigs := &httpmw.OAuth2Configs{
247+
Github: options.GithubOAuth2Config,
248+
OIDC: options.OIDCConfig,
249+
}
250+
239251
r := chi.NewRouter()
240252
ctx, cancel := context.WithCancel(context.Background())
241253
api := &API{
@@ -250,6 +262,15 @@ func New(options *Options) *API {
250262
Authorizer: options.Authorizer,
251263
Logger: options.Logger,
252264
},
265+
WorkspaceAppsProvider: workspaceapps.New(
266+
options.Logger.Named("workspaceapps"),
267+
options.AccessURL,
268+
options.Authorizer,
269+
options.Database,
270+
options.DeploymentConfig,
271+
oauthConfigs,
272+
options.AppSigningKey,
273+
),
253274
metricsCache: metricsCache,
254275
Auditor: atomic.Pointer[audit.Auditor]{},
255276
TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{},
@@ -266,20 +287,16 @@ func New(options *Options) *API {
266287
api.TemplateScheduleStore.Store(&options.TemplateScheduleStore)
267288
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
268289
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
269-
oauthConfigs := &httpmw.OAuth2Configs{
270-
Github: options.GithubOAuth2Config,
271-
OIDC: options.OIDCConfig,
272-
}
273290

274-
apiKeyMiddleware := httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
291+
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
275292
DB: options.Database,
276293
OAuth2Configs: oauthConfigs,
277294
RedirectToLogin: false,
278295
DisableSessionExpiryRefresh: options.DeploymentConfig.DisableSessionExpiryRefresh.Value,
279296
Optional: false,
280297
})
281298
// Same as above but it redirects to the login page.
282-
apiKeyMiddlewareRedirect := httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
299+
apiKeyMiddlewareRedirect := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
283300
DB: options.Database,
284301
OAuth2Configs: oauthConfigs,
285302
RedirectToLogin: true,
@@ -305,23 +322,9 @@ func New(options *Options) *API {
305322
httpmw.Prometheus(options.PrometheusRegistry),
306323
// handleSubdomainApplications checks if the first subdomain is a valid
307324
// app URL. If it is, it will serve that application.
308-
api.handleSubdomainApplications(
309-
apiRateLimiter,
310-
// Middleware to impose on the served application.
311-
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
312-
DB: options.Database,
313-
OAuth2Configs: oauthConfigs,
314-
// The code handles the the case where the user is not
315-
// authenticated automatically.
316-
RedirectToLogin: false,
317-
DisableSessionExpiryRefresh: options.DeploymentConfig.DisableSessionExpiryRefresh.Value,
318-
Optional: true,
319-
}),
320-
httpmw.AsAuthzSystem(
321-
httpmw.ExtractUserParam(api.Database, false),
322-
httpmw.ExtractWorkspaceAndAgentParam(api.Database),
323-
),
324-
),
325+
//
326+
// Workspace apps do their own auth.
327+
api.handleSubdomainApplications(apiRateLimiter),
325328
// Build-Version is helpful for debugging.
326329
func(next http.Handler) http.Handler {
327330
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -345,26 +348,8 @@ func New(options *Options) *API {
345348
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) })
346349

347350
apps := func(r chi.Router) {
348-
r.Use(
349-
apiRateLimiter,
350-
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
351-
DB: options.Database,
352-
OAuth2Configs: oauthConfigs,
353-
// Optional is true to allow for public apps. If an
354-
// authorization check fails and the user is not authenticated,
355-
// they will be redirected to the login page by the app handler.
356-
RedirectToLogin: false,
357-
DisableSessionExpiryRefresh: options.DeploymentConfig.DisableSessionExpiryRefresh.Value,
358-
Optional: true,
359-
}),
360-
httpmw.AsAuthzSystem(
361-
// Redirect to the login page if the user tries to open an app with
362-
// "me" as the username and they are not logged in.
363-
httpmw.ExtractUserParam(api.Database, true),
364-
// Extracts the <workspace.agent> from the url
365-
httpmw.ExtractWorkspaceAndAgentParam(api.Database),
366-
),
367-
)
351+
// Workspace apps do their own auth.
352+
r.Use(apiRateLimiter)
368353
r.HandleFunc("/*", api.workspaceAppsProxyPath)
369354
}
370355
// %40 is the encoded character of the @ symbol. VS Code Web does
@@ -742,9 +727,10 @@ type API struct {
742727
WebsocketWaitGroup sync.WaitGroup
743728
derpCloseFunc func()
744729

745-
metricsCache *metricscache.Cache
746-
workspaceAgentCache *wsconncache.Cache
747-
updateChecker *updatecheck.Checker
730+
metricsCache *metricscache.Cache
731+
workspaceAgentCache *wsconncache.Cache
732+
updateChecker *updatecheck.Checker
733+
WorkspaceAppsProvider *workspaceapps.Provider
748734

749735
// Experiments contains the list of experiments currently enabled.
750736
// This is used to gate features that are not yet ready for production.

coderd/coderdtest/coderdtest.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"crypto/x509"
1212
"crypto/x509/pkix"
1313
"encoding/base64"
14+
"encoding/hex"
1415
"encoding/json"
1516
"encoding/pem"
1617
"errors"
@@ -82,6 +83,10 @@ import (
8283
"github.com/coder/coder/testutil"
8384
)
8485

86+
// AppSigningKey is a 64-byte key used to sign JWTs for workspace app tickets in
87+
// tests.
88+
var AppSigningKey = must(hex.DecodeString("64656164626565666465616462656566646561646265656664656164626565666465616462656566646561646265656664656164626565666465616462656566"))
89+
8590
type Options struct {
8691
// AccessURL denotes a custom access URL. By default we use the httptest
8792
// server's URL. Setting this may result in unexpected behavior (especially
@@ -330,6 +335,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
330335
DeploymentConfig: options.DeploymentConfig,
331336
UpdateCheckOptions: options.UpdateCheckOptions,
332337
SwaggerEndpoint: options.SwaggerEndpoint,
338+
AppSigningKey: AppSigningKey,
333339
}
334340
}
335341

coderd/database/dbauthz/querier.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ func (q *querier) Ping(ctx context.Context) (time.Duration, error) {
1919
return q.db.Ping(ctx)
2020
}
2121

22+
func (q *querier) AcquireLock(ctx context.Context, id int64) error {
23+
return q.db.AcquireLock(ctx, id)
24+
}
25+
26+
func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) {
27+
return q.db.TryAcquireLock(ctx, id)
28+
}
29+
2230
// InTx runs the given function in a transaction.
2331
func (q *querier) InTx(function func(querier database.Store) error, txOpts *sql.TxOptions) error {
2432
return q.db.InTx(func(tx database.Store) error {
@@ -317,6 +325,16 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) {
317325
return q.db.GetLogoURL(ctx)
318326
}
319327

328+
func (q *querier) GetAppSigningKey(ctx context.Context) (string, error) {
329+
// No authz checks
330+
return q.db.GetAppSigningKey(ctx)
331+
}
332+
333+
func (q *querier) InsertAppSigningKey(ctx context.Context, data string) error {
334+
// No authz checks as this is done during startup
335+
return q.db.InsertAppSigningKey(ctx, data)
336+
}
337+
320338
func (q *querier) GetServiceBanner(ctx context.Context) (string, error) {
321339
// No authz checks
322340
return q.db.GetServiceBanner(ctx)

coderd/database/dbfake/databasefake.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func New() database.Store {
6464
workspaceApps: make([]database.WorkspaceApp, 0),
6565
workspaces: make([]database.Workspace, 0),
6666
licenses: make([]database.License, 0),
67+
locks: map[int64]struct{}{},
6768
},
6869
}
6970
}
@@ -89,6 +90,11 @@ type fakeQuerier struct {
8990
*data
9091
}
9192

93+
type fakeTx struct {
94+
*fakeQuerier
95+
locks map[int64]struct{}
96+
}
97+
9298
type data struct {
9399
// Legacy tables
94100
apiKeys []database.APIKey
@@ -124,11 +130,15 @@ type data struct {
124130
workspaceResources []database.WorkspaceResource
125131
workspaces []database.Workspace
126132

133+
// Locks is a map of lock names. Any keys within the map are currently
134+
// locked.
135+
locks map[int64]struct{}
127136
deploymentID string
128137
derpMeshKey string
129138
lastUpdateCheck []byte
130139
serviceBanner []byte
131140
logoURL string
141+
appSigningKey string
132142
lastLicenseID int32
133143
}
134144

@@ -196,11 +206,50 @@ func (*fakeQuerier) Ping(_ context.Context) (time.Duration, error) {
196206
return 0, nil
197207
}
198208

209+
func (*fakeQuerier) AcquireLock(_ context.Context, _ int64) error {
210+
return xerrors.New("AcquireLock must only be called within a transaction")
211+
}
212+
213+
func (*fakeQuerier) TryAcquireLock(_ context.Context, _ int64) (bool, error) {
214+
return false, xerrors.New("TryAcquireLock must only be called within a transaction")
215+
}
216+
217+
func (tx *fakeTx) AcquireLock(_ context.Context, id int64) error {
218+
if _, ok := tx.fakeQuerier.locks[id]; ok {
219+
return xerrors.Errorf("cannot acquire lock %d: already held", id)
220+
}
221+
tx.fakeQuerier.locks[id] = struct{}{}
222+
tx.locks[id] = struct{}{}
223+
return nil
224+
}
225+
226+
func (tx *fakeTx) TryAcquireLock(_ context.Context, id int64) (bool, error) {
227+
if _, ok := tx.fakeQuerier.locks[id]; ok {
228+
return false, nil
229+
}
230+
tx.fakeQuerier.locks[id] = struct{}{}
231+
tx.locks[id] = struct{}{}
232+
return true, nil
233+
}
234+
235+
func (tx *fakeTx) releaseLocks() {
236+
for id := range tx.locks {
237+
delete(tx.fakeQuerier.locks, id)
238+
}
239+
tx.locks = map[int64]struct{}{}
240+
}
241+
199242
// InTx doesn't rollback data properly for in-memory yet.
200243
func (q *fakeQuerier) InTx(fn func(database.Store) error, _ *sql.TxOptions) error {
201244
q.mutex.Lock()
202245
defer q.mutex.Unlock()
203-
return fn(&fakeQuerier{mutex: inTxMutex{}, data: q.data})
246+
tx := &fakeTx{
247+
fakeQuerier: &fakeQuerier{mutex: inTxMutex{}, data: q.data},
248+
locks: map[int64]struct{}{},
249+
}
250+
defer tx.releaseLocks()
251+
252+
return fn(tx)
204253
}
205254

206255
func (q *fakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.AcquireProvisionerJobParams) (database.ProvisionerJob, error) {
@@ -4004,6 +4053,21 @@ func (q *fakeQuerier) GetLogoURL(_ context.Context) (string, error) {
40044053
return q.logoURL, nil
40054054
}
40064055

4056+
func (q *fakeQuerier) GetAppSigningKey(_ context.Context) (string, error) {
4057+
q.mutex.RLock()
4058+
defer q.mutex.RUnlock()
4059+
4060+
return q.appSigningKey, nil
4061+
}
4062+
4063+
func (q *fakeQuerier) InsertAppSigningKey(_ context.Context, data string) error {
4064+
q.mutex.Lock()
4065+
defer q.mutex.Unlock()
4066+
4067+
q.appSigningKey = data
4068+
return nil
4069+
}
4070+
40074071
func (q *fakeQuerier) InsertLicense(
40084072
_ context.Context, arg database.InsertLicenseParams,
40094073
) (database.License, error) {

0 commit comments

Comments
 (0)