mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add cache abstraction for fetching signing keys (#14777)
- Adds the database implementation for fetching and caching keys used for JWT signing. It's been merged into the `keyrotate` pkg and renamed to `cryptokeys` since they're coupled concepts.
This commit is contained in:
Generated
+36
-34
@@ -9848,6 +9848,41 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.CryptoKey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletes_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"feature": {
|
||||
"$ref": "#/definitions/codersdk.CryptoKeyFeature"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.CryptoKeyFeature": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"workspace_apps",
|
||||
"oidc_convert",
|
||||
"tailnet_resume"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CryptoKeyFeatureWorkspaceApp",
|
||||
"CryptoKeyFeatureOIDCConvert",
|
||||
"CryptoKeyFeatureTailnetResume"
|
||||
]
|
||||
},
|
||||
"codersdk.CustomRoleRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15980,46 +16015,13 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.CryptoKey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletes_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"feature": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKeyFeature"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.CryptoKeyFeature": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"workspace_apps",
|
||||
"oidc_convert",
|
||||
"tailnet_resume"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CryptoKeyFeatureWorkspaceApp",
|
||||
"CryptoKeyFeatureOIDCConvert",
|
||||
"CryptoKeyFeatureTailnetResume"
|
||||
]
|
||||
},
|
||||
"wsproxysdk.CryptoKeysResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crypto_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKey"
|
||||
"$ref": "#/definitions/codersdk.CryptoKey"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+32
-30
@@ -8768,6 +8768,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.CryptoKey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletes_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"feature": {
|
||||
"$ref": "#/definitions/codersdk.CryptoKeyFeature"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.CryptoKeyFeature": {
|
||||
"type": "string",
|
||||
"enum": ["workspace_apps", "oidc_convert", "tailnet_resume"],
|
||||
"x-enum-varnames": [
|
||||
"CryptoKeyFeatureWorkspaceApp",
|
||||
"CryptoKeyFeatureOIDCConvert",
|
||||
"CryptoKeyFeatureTailnetResume"
|
||||
]
|
||||
},
|
||||
"codersdk.CustomRoleRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14611,42 +14642,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.CryptoKey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletes_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"feature": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKeyFeature"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.CryptoKeyFeature": {
|
||||
"type": "string",
|
||||
"enum": ["workspace_apps", "oidc_convert", "tailnet_resume"],
|
||||
"x-enum-varnames": [
|
||||
"CryptoKeyFeatureWorkspaceApp",
|
||||
"CryptoKeyFeatureOIDCConvert",
|
||||
"CryptoKeyFeatureTailnetResume"
|
||||
]
|
||||
},
|
||||
"wsproxysdk.CryptoKeysResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crypto_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKey"
|
||||
"$ref": "#/definitions/codersdk.CryptoKey"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package cryptokeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// never represents the maximum value for a time.Duration.
|
||||
const never = 1<<63 - 1
|
||||
|
||||
// DBCache implements Keycache for callers with access to the database.
|
||||
type DBCache struct {
|
||||
db database.Store
|
||||
feature database.CryptoKeyFeature
|
||||
logger slog.Logger
|
||||
clock quartz.Clock
|
||||
|
||||
// The following are initialized by NewDBCache.
|
||||
keysMu sync.RWMutex
|
||||
keys map[int32]database.CryptoKey
|
||||
latestKey database.CryptoKey
|
||||
timer *quartz.Timer
|
||||
// invalidateAt is the time at which the keys cache should be invalidated.
|
||||
invalidateAt time.Time
|
||||
closed bool
|
||||
}
|
||||
|
||||
type DBCacheOption func(*DBCache)
|
||||
|
||||
func WithDBCacheClock(clock quartz.Clock) DBCacheOption {
|
||||
return func(d *DBCache) {
|
||||
d.clock = clock
|
||||
}
|
||||
}
|
||||
|
||||
// NewDBCache creates a new DBCache. Close should be called to
|
||||
// release resources associated with its internal timer.
|
||||
func NewDBCache(logger slog.Logger, db database.Store, feature database.CryptoKeyFeature, opts ...func(*DBCache)) *DBCache {
|
||||
d := &DBCache{
|
||||
db: db,
|
||||
feature: feature,
|
||||
clock: quartz.NewReal(),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(d)
|
||||
}
|
||||
|
||||
d.timer = d.clock.AfterFunc(never, d.clear)
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// Verifying returns the CryptoKey with the given sequence number, provided that
|
||||
// it is neither deleted nor has breached its deletion date. It should only be
|
||||
// used for verifying or decrypting payloads. To sign/encrypt call Signing.
|
||||
func (d *DBCache) Verifying(ctx context.Context, sequence int32) (codersdk.CryptoKey, error) {
|
||||
d.keysMu.RLock()
|
||||
if d.closed {
|
||||
d.keysMu.RUnlock()
|
||||
return codersdk.CryptoKey{}, ErrClosed
|
||||
}
|
||||
|
||||
now := d.clock.Now()
|
||||
key, ok := d.keys[sequence]
|
||||
d.keysMu.RUnlock()
|
||||
if ok {
|
||||
return checkKey(key, now)
|
||||
}
|
||||
|
||||
d.keysMu.Lock()
|
||||
defer d.keysMu.Unlock()
|
||||
|
||||
if d.closed {
|
||||
return codersdk.CryptoKey{}, ErrClosed
|
||||
}
|
||||
|
||||
key, ok = d.keys[sequence]
|
||||
if ok {
|
||||
return checkKey(key, now)
|
||||
}
|
||||
|
||||
err := d.fetch(ctx)
|
||||
if err != nil {
|
||||
return codersdk.CryptoKey{}, xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
|
||||
key, ok = d.keys[sequence]
|
||||
if !ok {
|
||||
return codersdk.CryptoKey{}, ErrKeyNotFound
|
||||
}
|
||||
|
||||
return checkKey(key, now)
|
||||
}
|
||||
|
||||
// Signing returns the latest valid key for signing. A valid key is one that is
|
||||
// both past its start time and before its deletion time.
|
||||
func (d *DBCache) Signing(ctx context.Context) (codersdk.CryptoKey, error) {
|
||||
d.keysMu.RLock()
|
||||
|
||||
if d.closed {
|
||||
d.keysMu.RUnlock()
|
||||
return codersdk.CryptoKey{}, ErrClosed
|
||||
}
|
||||
|
||||
latest := d.latestKey
|
||||
d.keysMu.RUnlock()
|
||||
|
||||
now := d.clock.Now()
|
||||
if latest.CanSign(now) {
|
||||
return db2sdk.CryptoKey(latest), nil
|
||||
}
|
||||
|
||||
d.keysMu.Lock()
|
||||
defer d.keysMu.Unlock()
|
||||
|
||||
if d.closed {
|
||||
return codersdk.CryptoKey{}, ErrClosed
|
||||
}
|
||||
|
||||
if d.latestKey.CanSign(now) {
|
||||
return db2sdk.CryptoKey(d.latestKey), nil
|
||||
}
|
||||
|
||||
// Refetch all keys for this feature so we can find the latest valid key.
|
||||
err := d.fetch(ctx)
|
||||
if err != nil {
|
||||
return codersdk.CryptoKey{}, xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
|
||||
return db2sdk.CryptoKey(d.latestKey), nil
|
||||
}
|
||||
|
||||
// clear invalidates the cache. This forces the subsequent call to fetch fresh keys.
|
||||
func (d *DBCache) clear() {
|
||||
now := d.clock.Now("DBCache", "clear")
|
||||
d.keysMu.Lock()
|
||||
defer d.keysMu.Unlock()
|
||||
// Check if we raced with a fetch. It's possible that the timer fired and we
|
||||
// lost the race to the mutex. We want to avoid invalidating
|
||||
// a cache that was just refetched.
|
||||
if now.Before(d.invalidateAt) {
|
||||
return
|
||||
}
|
||||
d.keys = nil
|
||||
d.latestKey = database.CryptoKey{}
|
||||
}
|
||||
|
||||
// fetch fetches all keys for the given feature and determines the latest key.
|
||||
// It must be called while holding the keysMu lock.
|
||||
func (d *DBCache) fetch(ctx context.Context) error {
|
||||
keys, err := d.db.GetCryptoKeysByFeature(ctx, d.feature)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get crypto keys by feature: %w", err)
|
||||
}
|
||||
|
||||
now := d.clock.Now()
|
||||
_ = d.timer.Reset(time.Minute * 10)
|
||||
d.invalidateAt = now.Add(time.Minute * 10)
|
||||
|
||||
cache := make(map[int32]database.CryptoKey)
|
||||
var latest database.CryptoKey
|
||||
for _, key := range keys {
|
||||
cache[key.Sequence] = key
|
||||
if key.CanSign(now) && key.Sequence > latest.Sequence {
|
||||
latest = key
|
||||
}
|
||||
}
|
||||
|
||||
if len(cache) == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
|
||||
if !latest.CanSign(now) {
|
||||
return ErrKeyInvalid
|
||||
}
|
||||
|
||||
d.keys, d.latestKey = cache, latest
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkKey(key database.CryptoKey, now time.Time) (codersdk.CryptoKey, error) {
|
||||
if !key.CanVerify(now) {
|
||||
return codersdk.CryptoKey{}, ErrKeyInvalid
|
||||
}
|
||||
|
||||
return db2sdk.CryptoKey(key), nil
|
||||
}
|
||||
|
||||
func (d *DBCache) Close() {
|
||||
d.keysMu.Lock()
|
||||
defer d.keysMu.Unlock()
|
||||
|
||||
if d.closed {
|
||||
return
|
||||
}
|
||||
|
||||
d.timer.Stop()
|
||||
d.closed = true
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
package cryptokeys
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func Test_Verifying(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("HitsCache", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
logger = slogtest.Make(t, nil)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
)
|
||||
|
||||
expectedKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
cache := map[int32]database.CryptoKey{
|
||||
32: expectedKey,
|
||||
}
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
k.keys = cache
|
||||
|
||||
got, err := k.Verifying(ctx, 32)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
|
||||
})
|
||||
|
||||
t.Run("MissesCache", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
expectedKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 33,
|
||||
StartsAt: clock.Now(),
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{expectedKey}, nil)
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
got, err := k.Verifying(ctx, 33)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
|
||||
require.Equal(t, db2sdk.CryptoKey(expectedKey), db2sdk.CryptoKey(k.latestKey))
|
||||
})
|
||||
|
||||
t.Run("InvalidCachedKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
cache := map[int32]database.CryptoKey{
|
||||
32: {
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
DeletesAt: sql.NullTime{
|
||||
Time: clock.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
k.keys = cache
|
||||
|
||||
_, err := k.Verifying(ctx, 32)
|
||||
require.ErrorIs(t, err, ErrKeyInvalid)
|
||||
})
|
||||
|
||||
t.Run("InvalidDBKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
invalidKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
DeletesAt: sql.NullTime{
|
||||
Time: clock.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{invalidKey}, nil)
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
_, err := k.Verifying(ctx, 32)
|
||||
require.ErrorIs(t, err, ErrKeyInvalid)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_Signing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("HitsCache", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
latestKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now(),
|
||||
}
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
k.latestKey = latestKey
|
||||
|
||||
got, err := k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(latestKey), got)
|
||||
})
|
||||
|
||||
t.Run("InvalidCachedKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
latestKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 33,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now(),
|
||||
}
|
||||
|
||||
invalidKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now().Add(-time.Hour),
|
||||
DeletesAt: sql.NullTime{
|
||||
Time: clock.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{latestKey}, nil)
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
k.latestKey = invalidKey
|
||||
|
||||
got, err := k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(latestKey), got)
|
||||
})
|
||||
|
||||
t.Run("UsesActiveKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
inactiveKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
activeKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 33,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now(),
|
||||
}
|
||||
|
||||
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{inactiveKey, activeKey}, nil)
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
got, err := k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(activeKey), got)
|
||||
})
|
||||
|
||||
t.Run("NoValidKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
inactiveKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
invalidKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 33,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now().Add(-time.Hour),
|
||||
DeletesAt: sql.NullTime{
|
||||
Time: clock.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{inactiveKey, invalidKey}, nil)
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
_, err := k.Signing(ctx)
|
||||
require.ErrorIs(t, err, ErrKeyInvalid)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_clear(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("InvalidatesCache", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
activeKey := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 33,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now(),
|
||||
}
|
||||
|
||||
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{activeKey}, nil)
|
||||
|
||||
_, err := k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
dur, wait := clock.AdvanceNext()
|
||||
wait.MustWait(ctx)
|
||||
require.Equal(t, time.Minute*10, dur)
|
||||
require.Len(t, k.keys, 0)
|
||||
require.Equal(t, database.CryptoKey{}, k.latestKey)
|
||||
})
|
||||
|
||||
t.Run("ResetsTimer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
key := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now(),
|
||||
}
|
||||
|
||||
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{key}, nil)
|
||||
|
||||
// Advance it five minutes so that we can test that the
|
||||
// timer is reset and doesn't fire after another five minute.
|
||||
clock.Advance(time.Minute * 5)
|
||||
|
||||
latest, err := k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(key), latest)
|
||||
|
||||
// Advancing the clock now should require 10 minutes
|
||||
// before the timer fires again.
|
||||
dur, wait := clock.AdvanceNext()
|
||||
wait.MustWait(ctx)
|
||||
require.Equal(t, time.Minute*10, dur)
|
||||
require.Len(t, k.keys, 0)
|
||||
require.Equal(t, database.CryptoKey{}, k.latestKey)
|
||||
})
|
||||
|
||||
// InvalidateAt tests that we have accounted for the race condition where a
|
||||
// timer fires to invalidate the cache at the same time we are fetching new
|
||||
// keys. In such cases we want to skip invalidation.
|
||||
t.Run("InvalidateAt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
mockDB = dbmock.NewMockStore(ctrl)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
trap := clock.Trap().Now("clear")
|
||||
|
||||
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
key := database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 32,
|
||||
Secret: sql.NullString{
|
||||
String: "secret",
|
||||
Valid: true,
|
||||
},
|
||||
StartsAt: clock.Now(),
|
||||
}
|
||||
|
||||
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{key}, nil).Times(2)
|
||||
|
||||
// Move us past the initial timer.
|
||||
latest, err := k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(key), latest)
|
||||
// Null these out so that we refetch.
|
||||
k.keys = nil
|
||||
k.latestKey = database.CryptoKey{}
|
||||
|
||||
// Initiate firing the timer.
|
||||
dur, wait := clock.AdvanceNext()
|
||||
require.Equal(t, time.Minute*10, dur)
|
||||
// Trap the function just before acquiring the mutex.
|
||||
call := trap.MustWait(ctx)
|
||||
|
||||
// Refetch keys.
|
||||
latest, err = k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(key), latest)
|
||||
|
||||
// Let the rest of the timer function run.
|
||||
// It should see that we have refetched keys and
|
||||
// not invalidate.
|
||||
call.Release()
|
||||
wait.MustWait(ctx)
|
||||
require.Len(t, k.keys, 1)
|
||||
require.Equal(t, key, k.latestKey)
|
||||
trap.Close()
|
||||
|
||||
// Refetching the keys should've instantiated a new timer. This one should invalidate keys.
|
||||
_, wait = clock.AdvanceNext()
|
||||
wait.MustWait(ctx)
|
||||
require.Len(t, k.keys, 0)
|
||||
require.Equal(t, database.CryptoKey{}, k.latestKey)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package cryptokeys_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func TestDBKeyCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Verifying", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("HitsCache", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
db, _ = dbtestutil.NewDB(t)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
key := dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 1,
|
||||
StartsAt: clock.Now().UTC(),
|
||||
})
|
||||
|
||||
k := cryptokeys.NewDBCache(logger, db, database.CryptoKeyFeatureWorkspaceApps, cryptokeys.WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
got, err := k.Verifying(ctx, key.Sequence)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(key), got)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
db, _ = dbtestutil.NewDB(t)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
k := cryptokeys.NewDBCache(logger, db, database.CryptoKeyFeatureWorkspaceApps, cryptokeys.WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
_, err := k.Verifying(ctx, 123)
|
||||
require.ErrorIs(t, err, cryptokeys.ErrKeyNotFound)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Signing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
db, _ = dbtestutil.NewDB(t)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 10,
|
||||
StartsAt: clock.Now().UTC(),
|
||||
})
|
||||
|
||||
expectedKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 12,
|
||||
StartsAt: clock.Now().UTC(),
|
||||
})
|
||||
|
||||
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 2,
|
||||
StartsAt: clock.Now().UTC(),
|
||||
})
|
||||
|
||||
k := cryptokeys.NewDBCache(logger, db, database.CryptoKeyFeatureWorkspaceApps, cryptokeys.WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
got, err := k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
|
||||
})
|
||||
|
||||
t.Run("Closed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
db, _ = dbtestutil.NewDB(t)
|
||||
clock = quartz.NewMock(t)
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, nil)
|
||||
)
|
||||
|
||||
expectedKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 10,
|
||||
StartsAt: clock.Now(),
|
||||
})
|
||||
|
||||
k := cryptokeys.NewDBCache(logger, db, database.CryptoKeyFeatureWorkspaceApps, cryptokeys.WithDBCacheClock(clock))
|
||||
defer k.Close()
|
||||
|
||||
got, err := k.Signing(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
|
||||
|
||||
got, err = k.Verifying(ctx, expectedKey.Sequence)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
|
||||
|
||||
k.Close()
|
||||
|
||||
_, err = k.Signing(ctx)
|
||||
require.ErrorIs(t, err, cryptokeys.ErrClosed)
|
||||
|
||||
_, err = k.Verifying(ctx, expectedKey.Sequence)
|
||||
require.ErrorIs(t, err, cryptokeys.ErrClosed)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cryptokeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrKeyNotFound = xerrors.New("key not found")
|
||||
ErrKeyInvalid = xerrors.New("key is invalid for use")
|
||||
ErrClosed = xerrors.New("closed")
|
||||
)
|
||||
|
||||
// Keycache provides an abstraction for fetching signing keys.
|
||||
type Keycache interface {
|
||||
Signing(ctx context.Context) (codersdk.CryptoKey, error)
|
||||
Verifying(ctx context.Context, sequence int32) (codersdk.CryptoKey, error)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package keyrotate
|
||||
package cryptokeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -36,15 +36,15 @@ type rotator struct {
|
||||
features []database.CryptoKeyFeature
|
||||
}
|
||||
|
||||
type Option func(*rotator)
|
||||
type RotatorOption func(*rotator)
|
||||
|
||||
func WithClock(clock quartz.Clock) Option {
|
||||
func WithClock(clock quartz.Clock) RotatorOption {
|
||||
return func(r *rotator) {
|
||||
r.clock = clock
|
||||
}
|
||||
}
|
||||
|
||||
func WithKeyDuration(keyDuration time.Duration) Option {
|
||||
func WithKeyDuration(keyDuration time.Duration) RotatorOption {
|
||||
return func(r *rotator) {
|
||||
r.keyDuration = keyDuration
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func WithKeyDuration(keyDuration time.Duration) Option {
|
||||
// StartRotator starts a background process that rotates keys in the database.
|
||||
// It ensures there's at least one valid key per feature prior to returning.
|
||||
// Canceling the provided context will stop the background process.
|
||||
func StartRotator(ctx context.Context, logger slog.Logger, db database.Store, opts ...Option) error {
|
||||
func StartRotator(ctx context.Context, logger slog.Logger, db database.Store, opts ...RotatorOption) error {
|
||||
kr := &rotator{
|
||||
db: db,
|
||||
logger: logger,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package keyrotate
|
||||
package cryptokeys
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package keyrotate_test
|
||||
package cryptokeys_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/keyrotate"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
@@ -34,7 +34,7 @@ func TestRotator(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, dbkeys, 0)
|
||||
|
||||
err = keyrotate.StartRotator(ctx, logger, db, keyrotate.WithClock(clock))
|
||||
err = cryptokeys.StartRotator(ctx, logger, db, cryptokeys.WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fetch the keys from the database and ensure they
|
||||
@@ -59,14 +59,14 @@ func TestRotator(t *testing.T) {
|
||||
|
||||
rotatingKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
StartsAt: now.Add(-keyrotate.DefaultKeyDuration + time.Hour + time.Minute),
|
||||
StartsAt: now.Add(-cryptokeys.DefaultKeyDuration + time.Hour + time.Minute),
|
||||
Sequence: 12345,
|
||||
})
|
||||
|
||||
trap := clock.Trap().TickerFunc()
|
||||
t.Cleanup(trap.Close)
|
||||
|
||||
err := keyrotate.StartRotator(ctx, logger, db, keyrotate.WithClock(clock))
|
||||
err := cryptokeys.StartRotator(ctx, logger, db, cryptokeys.WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
|
||||
initialKeyLen := len(database.AllCryptoKeyFeatureValues())
|
||||
@@ -88,14 +88,14 @@ func TestRotator(t *testing.T) {
|
||||
newKey, err := db.GetLatestCryptoKeyByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rotatingKey.Sequence+1, newKey.Sequence)
|
||||
require.Equal(t, rotatingKey.ExpiresAt(keyrotate.DefaultKeyDuration), newKey.StartsAt.UTC())
|
||||
require.Equal(t, rotatingKey.ExpiresAt(cryptokeys.DefaultKeyDuration), newKey.StartsAt.UTC())
|
||||
require.False(t, newKey.DeletesAt.Valid)
|
||||
|
||||
oldKey, err := db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
||||
Feature: rotatingKey.Feature,
|
||||
Sequence: rotatingKey.Sequence,
|
||||
})
|
||||
expectedDeletesAt := rotatingKey.StartsAt.Add(keyrotate.DefaultKeyDuration + time.Hour + keyrotate.WorkspaceAppsTokenDuration)
|
||||
expectedDeletesAt := rotatingKey.StartsAt.Add(cryptokeys.DefaultKeyDuration + time.Hour + cryptokeys.WorkspaceAppsTokenDuration)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rotatingKey.StartsAt, oldKey.StartsAt)
|
||||
require.True(t, oldKey.DeletesAt.Valid)
|
||||
@@ -659,3 +659,17 @@ func Organization(organization database.Organization) codersdk.Organization {
|
||||
IsDefault: organization.IsDefault,
|
||||
}
|
||||
}
|
||||
|
||||
func CryptoKeys(keys []database.CryptoKey) []codersdk.CryptoKey {
|
||||
return List(keys, CryptoKey)
|
||||
}
|
||||
|
||||
func CryptoKey(key database.CryptoKey) codersdk.CryptoKey {
|
||||
return codersdk.CryptoKey{
|
||||
Feature: codersdk.CryptoKeyFeature(key.Feature),
|
||||
Sequence: key.Sequence,
|
||||
StartsAt: key.StartsAt,
|
||||
DeletesAt: key.DeletesAt.Time,
|
||||
Secret: key.Secret.String,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -920,7 +920,7 @@ func CryptoKey(t testing.TB, db database.Store, seed database.CryptoKey) databas
|
||||
Secret: seed.Secret,
|
||||
SecretKeyID: takeFirst(seed.SecretKeyID, sql.NullString{}),
|
||||
Feature: seed.Feature,
|
||||
StartsAt: takeFirst(seed.StartsAt, time.Now()),
|
||||
StartsAt: takeFirst(seed.StartsAt, dbtime.Now()),
|
||||
})
|
||||
require.NoError(t, err, "insert crypto key")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -451,3 +452,18 @@ func (r GetAuthorizationUserRolesRow) RoleNames() ([]rbac.RoleIdentifier, error)
|
||||
func (k CryptoKey) ExpiresAt(keyDuration time.Duration) time.Time {
|
||||
return k.StartsAt.Add(keyDuration).UTC()
|
||||
}
|
||||
|
||||
func (k CryptoKey) DecodeString() ([]byte, error) {
|
||||
return hex.DecodeString(k.Secret.String)
|
||||
}
|
||||
|
||||
func (k CryptoKey) CanSign(now time.Time) bool {
|
||||
isAfterStart := !k.StartsAt.IsZero() && !now.Before(k.StartsAt)
|
||||
return isAfterStart && k.CanVerify(now)
|
||||
}
|
||||
|
||||
func (k CryptoKey) CanVerify(now time.Time) bool {
|
||||
hasSecret := k.Secret.Valid
|
||||
isBeforeDeletion := !k.DeletesAt.Valid || now.Before(k.DeletesAt.Time)
|
||||
return hasSecret && isBeforeDeletion
|
||||
}
|
||||
|
||||
@@ -3101,3 +3101,32 @@ func (c *Client) SSHConfiguration(ctx context.Context) (SSHConfigResponse, error
|
||||
var sshConfig SSHConfigResponse
|
||||
return sshConfig, json.NewDecoder(res.Body).Decode(&sshConfig)
|
||||
}
|
||||
|
||||
type CryptoKeyFeature string
|
||||
|
||||
const (
|
||||
CryptoKeyFeatureWorkspaceApp CryptoKeyFeature = "workspace_apps"
|
||||
CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert"
|
||||
CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume"
|
||||
)
|
||||
|
||||
type CryptoKey struct {
|
||||
Feature CryptoKeyFeature `json:"feature"`
|
||||
Secret string `json:"secret"`
|
||||
DeletesAt time.Time `json:"deletes_at" format:"date-time"`
|
||||
Sequence int32 `json:"sequence"`
|
||||
StartsAt time.Time `json:"starts_at" format:"date-time"`
|
||||
}
|
||||
|
||||
func (c CryptoKey) CanSign(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
isAfterStartsAt := !c.StartsAt.IsZero() && !now.Before(c.StartsAt)
|
||||
return isAfterStartsAt && c.CanVerify(now)
|
||||
}
|
||||
|
||||
func (c CryptoKey) CanVerify(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
hasSecret := c.Secret != ""
|
||||
beforeDelete := c.DeletesAt.IsZero() || now.Before(c.DeletesAt)
|
||||
return hasSecret && beforeDelete
|
||||
}
|
||||
|
||||
Generated
+43
-43
@@ -1406,6 +1406,44 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `template_version_id` | string | false | | Template version ID can be used to specify a specific version of a template for creating the workspace. |
|
||||
| `ttl_ms` | integer | false | | |
|
||||
|
||||
## codersdk.CryptoKey
|
||||
|
||||
```json
|
||||
{
|
||||
"deletes_at": "2019-08-24T14:15:22Z",
|
||||
"feature": "workspace_apps",
|
||||
"secret": "string",
|
||||
"sequence": 0,
|
||||
"starts_at": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------ | ------------------------------------------------------ | -------- | ------------ | ----------- |
|
||||
| `deletes_at` | string | false | | |
|
||||
| `feature` | [codersdk.CryptoKeyFeature](#codersdkcryptokeyfeature) | false | | |
|
||||
| `secret` | string | false | | |
|
||||
| `sequence` | integer | false | | |
|
||||
| `starts_at` | string | false | | |
|
||||
|
||||
## codersdk.CryptoKeyFeature
|
||||
|
||||
```json
|
||||
"workspace_apps"
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value |
|
||||
| ---------------- |
|
||||
| `workspace_apps` |
|
||||
| `oidc_convert` |
|
||||
| `tailnet_resume` |
|
||||
|
||||
## codersdk.CustomRoleRequest
|
||||
|
||||
```json
|
||||
@@ -9780,55 +9818,17 @@ _None_
|
||||
| `derp_map` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | |
|
||||
| `disable_direct_connections` | boolean | false | | |
|
||||
|
||||
## wsproxysdk.CryptoKey
|
||||
|
||||
```json
|
||||
{
|
||||
"deletes_at": "string",
|
||||
"feature": "workspace_apps",
|
||||
"secret": "string",
|
||||
"sequence": 0,
|
||||
"starts_at": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------ | ---------------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `deletes_at` | string | false | | |
|
||||
| `feature` | [wsproxysdk.CryptoKeyFeature](#wsproxysdkcryptokeyfeature) | false | | |
|
||||
| `secret` | string | false | | |
|
||||
| `sequence` | integer | false | | |
|
||||
| `starts_at` | string | false | | |
|
||||
|
||||
## wsproxysdk.CryptoKeyFeature
|
||||
|
||||
```json
|
||||
"workspace_apps"
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value |
|
||||
| ---------------- |
|
||||
| `workspace_apps` |
|
||||
| `oidc_convert` |
|
||||
| `tailnet_resume` |
|
||||
|
||||
## wsproxysdk.CryptoKeysResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"crypto_keys": [
|
||||
{
|
||||
"deletes_at": "string",
|
||||
"deletes_at": "2019-08-24T14:15:22Z",
|
||||
"feature": "workspace_apps",
|
||||
"secret": "string",
|
||||
"sequence": 0,
|
||||
"starts_at": "string"
|
||||
"starts_at": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9836,9 +9836,9 @@ _None_
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------- | ----------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `crypto_keys` | array of [wsproxysdk.CryptoKey](#wsproxysdkcryptokey) | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------- | ------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `crypto_keys` | array of [codersdk.CryptoKey](#codersdkcryptokey) | false | | |
|
||||
|
||||
## wsproxysdk.DeregisterWorkspaceProxyRequest
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
agpl "github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
@@ -733,7 +734,7 @@ func (api *API) workspaceProxyCryptoKeys(rw http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, wsproxysdk.CryptoKeysResponse{
|
||||
CryptoKeys: fromDBCryptoKeys(keys),
|
||||
CryptoKeys: db2sdk.CryptoKeys(keys),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -994,17 +995,3 @@ func (w *workspaceProxiesFetchUpdater) Fetch(ctx context.Context) (codersdk.Regi
|
||||
func (w *workspaceProxiesFetchUpdater) Update(ctx context.Context) error {
|
||||
return w.updateFunc(ctx)
|
||||
}
|
||||
|
||||
func fromDBCryptoKeys(keys []database.CryptoKey) []wsproxysdk.CryptoKey {
|
||||
wskeys := make([]wsproxysdk.CryptoKey, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
wskeys = append(wskeys, wsproxysdk.CryptoKey{
|
||||
Feature: wsproxysdk.CryptoKeyFeature(key.Feature),
|
||||
Sequence: key.Sequence,
|
||||
StartsAt: key.StartsAt.UTC(),
|
||||
DeletesAt: key.DeletesAt.Time.UTC(),
|
||||
Secret: key.Secret.String,
|
||||
})
|
||||
}
|
||||
return wskeys
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
@@ -911,21 +912,21 @@ func TestGetCryptoKeys(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
now := time.Now().UTC()
|
||||
now := time.Now()
|
||||
|
||||
expectedKey1 := dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
StartsAt: now.Add(-time.Hour),
|
||||
Sequence: 2,
|
||||
})
|
||||
key1 := fromDBCryptoKeys(expectedKey1)
|
||||
key1 := db2sdk.CryptoKey(expectedKey1)
|
||||
|
||||
expectedKey2 := dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
StartsAt: now,
|
||||
Sequence: 3,
|
||||
})
|
||||
key2 := fromDBCryptoKeys(expectedKey2)
|
||||
key2 := db2sdk.CryptoKey(expectedKey2)
|
||||
|
||||
// Create a deleted key.
|
||||
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
@@ -958,8 +959,7 @@ func TestGetCryptoKeys(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, keys)
|
||||
require.Equal(t, 2, len(keys.CryptoKeys))
|
||||
require.Contains(t, keys.CryptoKeys, key1)
|
||||
require.Contains(t, keys.CryptoKeys, key2)
|
||||
requireContainsKeys(t, keys.CryptoKeys, key1, key2)
|
||||
})
|
||||
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
@@ -995,12 +995,19 @@ func TestGetCryptoKeys(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func fromDBCryptoKeys(key database.CryptoKey) wsproxysdk.CryptoKey {
|
||||
return wsproxysdk.CryptoKey{
|
||||
Feature: wsproxysdk.CryptoKeyFeature(key.Feature),
|
||||
Sequence: key.Sequence,
|
||||
StartsAt: key.StartsAt.UTC(),
|
||||
DeletesAt: key.DeletesAt.Time.UTC(),
|
||||
Secret: key.Secret.String,
|
||||
func requireContainsKeys(t *testing.T, keys []codersdk.CryptoKey, expected ...codersdk.CryptoKey) {
|
||||
t.Helper()
|
||||
|
||||
for _, expectedKey := range expected {
|
||||
var found bool
|
||||
for _, key := range keys {
|
||||
if key.Feature == expectedKey.Feature && key.Sequence == expectedKey.Sequence {
|
||||
require.True(t, expectedKey.StartsAt.Equal(key.StartsAt), "expected starts at %s, got %s", expectedKey.StartsAt, key.StartsAt)
|
||||
require.Equal(t, expectedKey.Secret, key.Secret)
|
||||
require.True(t, expectedKey.DeletesAt.Equal(key.DeletesAt), "expected deletes at %s, got %s", expectedKey.DeletesAt, key.DeletesAt)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected key %+v not found", expectedKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,35 +204,6 @@ type RegisterWorkspaceProxyRequest struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type CryptoKeyFeature string
|
||||
|
||||
const (
|
||||
CryptoKeyFeatureWorkspaceApp CryptoKeyFeature = "workspace_apps"
|
||||
CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert"
|
||||
CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume"
|
||||
)
|
||||
|
||||
type CryptoKey struct {
|
||||
Feature CryptoKeyFeature `json:"feature"`
|
||||
Secret string `json:"secret"`
|
||||
DeletesAt time.Time `json:"deletes_at"`
|
||||
Sequence int32 `json:"sequence"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
}
|
||||
|
||||
func (c CryptoKey) CanSign(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
isAfterStartsAt := !c.StartsAt.IsZero() && !now.Before(c.StartsAt)
|
||||
return isAfterStartsAt && c.CanVerify(now)
|
||||
}
|
||||
|
||||
func (c CryptoKey) CanVerify(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
hasSecret := c.Secret != ""
|
||||
beforeDelete := c.DeletesAt.IsZero() || now.Before(c.DeletesAt)
|
||||
return hasSecret && beforeDelete
|
||||
}
|
||||
|
||||
type RegisterWorkspaceProxyResponse struct {
|
||||
AppSecurityKey string `json:"app_security_key"`
|
||||
DERPMeshKey string `json:"derp_mesh_key"`
|
||||
@@ -612,7 +583,7 @@ func (c *Client) DialCoordinator(ctx context.Context) (agpl.MultiAgentConn, erro
|
||||
}
|
||||
|
||||
type CryptoKeysResponse struct {
|
||||
CryptoKeys []CryptoKey `json:"crypto_keys"`
|
||||
CryptoKeys []codersdk.CryptoKey `json:"crypto_keys"`
|
||||
}
|
||||
|
||||
func (c *Client) CryptoKeys(ctx context.Context) (CryptoKeysResponse, error) {
|
||||
|
||||
Generated
+13
@@ -343,6 +343,15 @@ export interface CreateWorkspaceRequest {
|
||||
readonly automatic_updates?: AutomaticUpdates;
|
||||
}
|
||||
|
||||
// From codersdk/deployment.go
|
||||
export interface CryptoKey {
|
||||
readonly feature: CryptoKeyFeature;
|
||||
readonly secret: string;
|
||||
readonly deletes_at: string;
|
||||
readonly sequence: number;
|
||||
readonly starts_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/roles.go
|
||||
export interface CustomRoleRequest {
|
||||
readonly name: string;
|
||||
@@ -2077,6 +2086,10 @@ export const AutomaticUpdateses: AutomaticUpdates[] = ["always", "never"]
|
||||
export type BuildReason = "autostart" | "autostop" | "initiator"
|
||||
export const BuildReasons: BuildReason[] = ["autostart", "autostop", "initiator"]
|
||||
|
||||
// From codersdk/deployment.go
|
||||
export type CryptoKeyFeature = "oidc_convert" | "tailnet_resume" | "workspace_apps"
|
||||
export const CryptoKeyFeatures: CryptoKeyFeature[] = ["oidc_convert", "tailnet_resume", "workspace_apps"]
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
export type DisplayApp = "port_forwarding_helper" | "ssh_helper" | "vscode" | "vscode_insiders" | "web_terminal"
|
||||
export const DisplayApps: DisplayApp[] = ["port_forwarding_helper", "ssh_helper", "vscode", "vscode_insiders", "web_terminal"]
|
||||
|
||||
Reference in New Issue
Block a user