Skip to content

Commit 66048de

Browse files
johnstcnkylecarbs
andcommitted
plumb through dbcrypt in enterprise/coderd:
- Adds a command dbcrypt-rotate to re-enncrypt encrypted data - Plumbs through dbcrypt in enterprise/coderd (including unit tests) - Enables database encryption in develop.sh by default Co-authored-by: Kyle Carberry <kyle@coder.com>
1 parent 8d5701a commit 66048de

28 files changed

+485
-30
lines changed

cli/server.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
685685
options.Database = dbfake.New()
686686
options.Pubsub = pubsub.NewInMemory()
687687
} else {
688-
sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String())
688+
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String())
689689
if err != nil {
690690
return xerrors.Errorf("connect to postgres: %w", err)
691691
}
@@ -1950,7 +1950,7 @@ func BuildLogger(inv *clibase.Invocation, cfg *codersdk.DeploymentValues) (slog.
19501950
}, nil
19511951
}
19521952

1953-
func connectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) {
1953+
func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) {
19541954
logger.Debug(ctx, "connecting to postgresql")
19551955

19561956
// Try to connect for 30 seconds.

cli/server_createadminuser.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd {
6262
newUserDBURL = url
6363
}
6464

65-
sqlDB, err := connectToPostgres(ctx, logger, "postgres", newUserDBURL)
65+
sqlDB, err := ConnectToPostgres(ctx, logger, "postgres", newUserDBURL)
6666
if err != nil {
6767
return xerrors.Errorf("connect to postgres: %w", err)
6868
}

cli/testdata/coder_server_--help.golden

+8
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,14 @@ These options are only available in the Enterprise Edition.
458458
An HTTP URL that is accessible by other replicas to relay DERP
459459
traffic. Required for high availability.
460460

461+
--external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS
462+
Encrypt OIDC and Git authentication tokens with AES-256-GCM in the
463+
database. The value must be a comma-separated list of base64-encoded
464+
keys. A maximum of two keys may be provided. Each key, when
465+
base64-decoded, must be exactly 32 bytes in length. The first key will
466+
be used to encrypt new values. Subsequent keys will be used as a
467+
fallback when decrypting.
468+
461469
--scim-auth-header string, $CODER_SCIM_AUTH_HEADER
462470
Enables SCIM and sets the authentication header for the built-in SCIM
463471
server. New users are automatically created with OIDC authentication.

coderd/apidoc/docs.go

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/deployment_test.go

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func TestDeploymentValues(t *testing.T) {
2626
cfg.OIDC.EmailField.Set("some_random_field_you_never_expected")
2727
cfg.PostgresURL.Set(hi)
2828
cfg.SCIMAPIKey.Set(hi)
29+
cfg.ExternalTokenEncryptionKeys.Set("the_random_key_we_never_expected,an_other_key_we_never_unexpected")
2930

3031
client := coderdtest.New(t, &coderdtest.Options{
3132
DeploymentValues: cfg,
@@ -44,6 +45,7 @@ func TestDeploymentValues(t *testing.T) {
4445
require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value())
4546
require.Empty(t, scrubbed.Values.PostgresURL.Value())
4647
require.Empty(t, scrubbed.Values.SCIMAPIKey.Value())
48+
require.Empty(t, scrubbed.Values.ExternalTokenEncryptionKeys.Value())
4749
}
4850

4951
func TestDeploymentStats(t *testing.T) {

coderd/httpmw/apikey.go

+6
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
247247
UserID: key.UserID,
248248
LoginType: key.LoginType,
249249
})
250+
if errors.Is(err, sql.ErrNoRows) {
251+
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
252+
Message: SignedOutErrorMessage,
253+
Detail: "You must re-authenticate with the login provider.",
254+
})
255+
}
250256
if err != nil {
251257
return write(http.StatusInternalServerError, codersdk.Response{
252258
Message: "A database error occurred",

codersdk/deployment.go

+14-3
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ const (
4646
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
4747
FeatureAppearance FeatureName = "appearance"
4848
FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling"
49-
FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement"
5049
FeatureWorkspaceProxy FeatureName = "workspace_proxy"
50+
FeatureExternalTokenEncryption FeatureName = "external_token_encryption"
51+
FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement"
5152
)
5253

5354
// FeatureNames must be kept in-sync with the Feature enum above.
@@ -64,6 +65,8 @@ var FeatureNames = []FeatureName{
6465
FeatureAdvancedTemplateScheduling,
6566
FeatureWorkspaceProxy,
6667
FeatureUserRoleManagement,
68+
FeatureExternalTokenEncryption,
69+
FeatureTemplateAutostopRequirement,
6770
}
6871

6972
// Humanize returns the feature name in a human-readable format.
@@ -152,6 +155,7 @@ type DeploymentValues struct {
152155
AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"`
153156
BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"`
154157
SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"`
158+
ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"`
155159
Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"`
156160
RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"`
157161
Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"`
@@ -1603,7 +1607,14 @@ when required by your organization's security policy.`,
16031607
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
16041608
Value: &c.SCIMAPIKey,
16051609
},
1606-
1610+
{
1611+
Name: "External Token Encryption Keys",
1612+
Description: "Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a comma-separated list of base64-encoded keys. A maximum of two keys may be provided. Each key, when base64-decoded, must be exactly 32 bytes in length. The first key will be used to encrypt new values. Subsequent keys will be used as a fallback when decrypting.",
1613+
Flag: "external-token-encryption-keys",
1614+
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS",
1615+
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
1616+
Value: &c.ExternalTokenEncryptionKeys,
1617+
},
16071618
{
16081619
Name: "Disable Path Apps",
16091620
Description: "Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured.",
@@ -1781,7 +1792,7 @@ func (c *DeploymentValues) WithoutSecrets() (*DeploymentValues, error) {
17811792

17821793
// This only works with string values for now.
17831794
switch v := opt.Value.(type) {
1784-
case *clibase.String:
1795+
case *clibase.String, *clibase.StringArray:
17851796
err := v.Set("")
17861797
if err != nil {
17871798
panic(err)

codersdk/deployment_test.go

+3
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) {
5757
"SCIM API Key": {
5858
yaml: true,
5959
},
60+
"External Token Encryption Keys": {
61+
yaml: true,
62+
},
6063
// These complex objects should be configured through YAML.
6164
"Support Links": {
6265
flag: true,

docs/api/general.md

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/api/schemas.md

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli.md

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
2727
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
2828
| [<code>config-ssh</code>](./cli/config-ssh.md) | Add an SSH Host entry for your workspaces "ssh coder.workspace" |
2929
| [<code>create</code>](./cli/create.md) | Create a workspace |
30+
| [<code>dbcrypt-rotate</code>](./cli/dbcrypt-rotate.md) | Rotate database encryption keys |
3031
| [<code>delete</code>](./cli/delete.md) | Delete a workspace |
3132
| [<code>dotfiles</code>](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
3233
| [<code>features</code>](./cli/features.md) | List Enterprise features |

docs/cli/dbcrypt-rotate.md

+31
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/server.md

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

enterprise/cli/dbcrypt_rotate.go

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
//go:build !slim
2+
3+
package cli
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"encoding/base64"
9+
10+
"cdr.dev/slog"
11+
"cdr.dev/slog/sloggers/sloghuman"
12+
13+
"github.com/coder/coder/v2/cli"
14+
"github.com/coder/coder/v2/cli/clibase"
15+
"github.com/coder/coder/v2/coderd/database"
16+
"github.com/coder/coder/v2/codersdk"
17+
"github.com/coder/coder/v2/enterprise/dbcrypt"
18+
19+
"golang.org/x/xerrors"
20+
)
21+
22+
func (*RootCmd) dbcryptRotate() *clibase.Cmd {
23+
var (
24+
vals = new(codersdk.DeploymentValues)
25+
opts = vals.Options()
26+
)
27+
cmd := &clibase.Cmd{
28+
Use: "dbcrypt-rotate --postgres-url <postgres_url> --external-token-encryption-keys <new-key>,<old-key>",
29+
Short: "Rotate database encryption keys",
30+
Options: clibase.OptionSet{
31+
*opts.ByName("Postgres Connection URL"),
32+
*opts.ByName("External Token Encryption Keys"),
33+
},
34+
Middleware: clibase.Chain(
35+
clibase.RequireNArgs(0),
36+
),
37+
Handler: func(inv *clibase.Invocation) error {
38+
ctx, cancel := context.WithCancel(inv.Context())
39+
defer cancel()
40+
logger := slog.Make(sloghuman.Sink(inv.Stdout))
41+
42+
if vals.PostgresURL == "" {
43+
return xerrors.Errorf("no database configured")
44+
}
45+
46+
if vals.ExternalTokenEncryptionKeys == nil || len(vals.ExternalTokenEncryptionKeys) != 2 {
47+
return xerrors.Errorf("dbcrypt-rotate requires exactly two external token encryption keys")
48+
}
49+
50+
newKey, err := base64.StdEncoding.DecodeString(vals.ExternalTokenEncryptionKeys[0])
51+
if err != nil {
52+
return xerrors.Errorf("new key must be base64-encoded")
53+
}
54+
oldKey, err := base64.StdEncoding.DecodeString(vals.ExternalTokenEncryptionKeys[1])
55+
if err != nil {
56+
return xerrors.Errorf("old key must be base64-encoded")
57+
}
58+
if bytes.Equal(newKey, oldKey) {
59+
return xerrors.Errorf("old and new keys must be different")
60+
}
61+
62+
primaryCipher, err := dbcrypt.CipherAES256(newKey)
63+
if err != nil {
64+
return xerrors.Errorf("create primary cipher: %w", err)
65+
}
66+
secondaryCipher, err := dbcrypt.CipherAES256(oldKey)
67+
if err != nil {
68+
return xerrors.Errorf("create secondary cipher: %w", err)
69+
}
70+
ciphers := dbcrypt.NewCiphers(primaryCipher, secondaryCipher)
71+
72+
sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", vals.PostgresURL.Value())
73+
if err != nil {
74+
return xerrors.Errorf("connect to postgres: %w", err)
75+
}
76+
defer func() {
77+
_ = sqlDB.Close()
78+
}()
79+
logger.Info(ctx, "connected to postgres")
80+
81+
db := database.New(sqlDB)
82+
83+
cryptDB, err := dbcrypt.New(ctx, db, ciphers)
84+
if err != nil {
85+
return xerrors.Errorf("create cryptdb: %w", err)
86+
}
87+
88+
users, err := cryptDB.GetUsers(ctx, database.GetUsersParams{})
89+
if err != nil {
90+
return xerrors.Errorf("get users: %w", err)
91+
}
92+
for idx, usr := range users {
93+
userLinks, err := cryptDB.GetUserLinksByUserID(ctx, usr.ID)
94+
if err != nil {
95+
return xerrors.Errorf("get user links for user: %w", err)
96+
}
97+
for _, userLink := range userLinks {
98+
if _, err := cryptDB.UpdateUserLink(ctx, database.UpdateUserLinkParams{
99+
OAuthAccessToken: userLink.OAuthAccessToken,
100+
OAuthRefreshToken: userLink.OAuthRefreshToken,
101+
OAuthExpiry: userLink.OAuthExpiry,
102+
UserID: usr.ID,
103+
LoginType: usr.LoginType,
104+
}); err != nil {
105+
return xerrors.Errorf("update user link: %w", err)
106+
}
107+
}
108+
gitAuthLinks, err := cryptDB.GetGitAuthLinksByUserID(ctx, usr.ID)
109+
if err != nil {
110+
return xerrors.Errorf("get git auth links for user: %w", err)
111+
}
112+
for _, gitAuthLink := range gitAuthLinks {
113+
if _, err := cryptDB.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
114+
ProviderID: gitAuthLink.ProviderID,
115+
UserID: usr.ID,
116+
UpdatedAt: gitAuthLink.UpdatedAt,
117+
OAuthAccessToken: gitAuthLink.OAuthAccessToken,
118+
OAuthRefreshToken: gitAuthLink.OAuthRefreshToken,
119+
OAuthExpiry: gitAuthLink.OAuthExpiry,
120+
}); err != nil {
121+
return xerrors.Errorf("update git auth link: %w", err)
122+
}
123+
}
124+
logger.Info(ctx, "encrypted user tokens", slog.F("current", idx+1), slog.F("of", len(users)))
125+
}
126+
logger.Info(ctx, "operation completed successfully")
127+
return nil
128+
},
129+
}
130+
return cmd
131+
}

0 commit comments

Comments
 (0)