feat(coderd): connect dbcrypt package implementation (#9523)

See also: https://github.com/coder/coder/pull/9522

- Adds commands `server dbcrypt {rotate,decrypt,delete}` to re-encrypt, decrypt, or delete encrypted data, respectively.
- Plumbs through dbcrypt in enterprise/coderd (including unit tests).
- Adds documentation in admin/encryption.md.

This enables dbcrypt by default, but the feature is soft-enforced on supplying external token encryption keys. Without specifying any keys, encryption/decryption is a no-op.
This commit is contained in:
Cian Johnston 2023-09-07 15:49:49 +01:00 committed by GitHub
parent ed7f682fd1
commit 7d7c84bb4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1600 additions and 36 deletions

View File

@ -691,7 +691,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
options.Database = dbfake.New()
options.Pubsub = pubsub.NewInMemory()
} else {
sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String())
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String())
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}
@ -1953,7 +1953,7 @@ func BuildLogger(inv *clibase.Invocation, cfg *codersdk.DeploymentValues) (slog.
}, nil
}
func connectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) {
func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) {
logger.Debug(ctx, "connecting to postgresql")
// Try to connect for 30 seconds.

View File

@ -63,7 +63,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd {
newUserDBURL = url
}
sqlDB, err := connectToPostgres(ctx, logger, "postgres", newUserDBURL)
sqlDB, err := ConnectToPostgres(ctx, logger, "postgres", newUserDBURL)
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}

View File

@ -34,10 +34,13 @@ import (
"go.uber.org/goleak"
"gopkg.in/yaml.v3"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/postgres"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/codersdk"
@ -1657,3 +1660,26 @@ func TestServerYAMLConfig(t *testing.T) {
require.Equal(t, string(wantByt), string(got))
}
func TestConnectToPostgres(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("this test does not make sense without postgres")
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
log := slogtest.Make(t, nil)
dbURL, closeFunc, err := postgres.Open()
require.NoError(t, err)
t.Cleanup(closeFunc)
sqlDB, err := cli.ConnectToPostgres(ctx, log, "postgres", dbURL)
require.NoError(t, err)
t.Cleanup(func() {
_ = sqlDB.Close()
})
require.NoError(t, sqlDB.PingContext(ctx))
}

View File

@ -458,6 +458,16 @@ These options are only available in the Enterprise Edition.
An HTTP URL that is accessible by other replicas to relay DERP
traffic. Required for high availability.
--external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS
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. 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. During normal
operation it is recommended to only set one key unless you are in the
process of rotating keys with the `coder server dbcrypt rotate`
command.
--scim-auth-header string, $CODER_SCIM_AUTH_HEADER
Enables SCIM and sets the authentication header for the built-in SCIM
server. New users are automatically created with OIDC authentication.

6
coderd/apidoc/docs.go generated
View File

@ -7956,6 +7956,12 @@ const docTemplate = `{
"type": "string"
}
},
"external_token_encryption_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"git_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig"
},

View File

@ -7112,6 +7112,12 @@
"type": "string"
}
},
"external_token_encryption_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"git_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig"
},

View File

@ -26,6 +26,7 @@ func TestDeploymentValues(t *testing.T) {
cfg.OIDC.EmailField.Set("some_random_field_you_never_expected")
cfg.PostgresURL.Set(hi)
cfg.SCIMAPIKey.Set(hi)
cfg.ExternalTokenEncryptionKeys.Set("the_random_key_we_never_expected,an_other_key_we_never_unexpected")
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: cfg,
@ -44,6 +45,7 @@ func TestDeploymentValues(t *testing.T) {
require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value())
require.Empty(t, scrubbed.Values.PostgresURL.Value())
require.Empty(t, scrubbed.Values.SCIMAPIKey.Value())
require.Empty(t, scrubbed.Values.ExternalTokenEncryptionKeys.Value())
}
func TestDeploymentStats(t *testing.T) {

View File

@ -248,6 +248,12 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
UserID: key.UserID,
LoginType: key.LoginType,
})
if errors.Is(err, sql.ErrNoRows) {
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: "You must re-authenticate with the login provider.",
})
}
if err != nil {
return write(http.StatusInternalServerError, codersdk.Response{
Message: "A database error occurred",

View File

@ -153,6 +153,34 @@ func TestAPIKey(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("UserLinkNotFound", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
user = dbgen.User(t, db, database.User{
LoginType: database.LoginTypeGithub,
})
// Intentionally not inserting any user link
_, token = dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
LoginType: user.LoginType,
})
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
var resp codersdk.Response
require.NoError(t, json.NewDecoder(res.Body).Decode(&resp))
require.Equal(t, resp.Message, httpmw.SignedOutErrorMessage)
})
t.Run("InvalidSecret", func(t *testing.T) {
t.Parallel()
var (

View File

@ -46,8 +46,9 @@ const (
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
FeatureAppearance FeatureName = "appearance"
FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling"
FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement"
FeatureWorkspaceProxy FeatureName = "workspace_proxy"
FeatureExternalTokenEncryption FeatureName = "external_token_encryption"
FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement"
FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions"
)
@ -65,6 +66,8 @@ var FeatureNames = []FeatureName{
FeatureAdvancedTemplateScheduling,
FeatureWorkspaceProxy,
FeatureUserRoleManagement,
FeatureExternalTokenEncryption,
FeatureTemplateAutostopRequirement,
FeatureWorkspaceBatchActions,
}
@ -154,6 +157,7 @@ type DeploymentValues struct {
AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"`
BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"`
SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"`
ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"`
Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"`
RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"`
Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"`
@ -1605,7 +1609,14 @@ when required by your organization's security policy.`,
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
Value: &c.SCIMAPIKey,
},
{
Name: "External Token Encryption Keys",
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. 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. During normal operation it is recommended to only set one key unless you are in the process of rotating keys with the `coder server dbcrypt rotate` command.",
Flag: "external-token-encryption-keys",
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS",
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
Value: &c.ExternalTokenEncryptionKeys,
},
{
Name: "Disable Path Apps",
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.",
@ -1783,7 +1794,7 @@ func (c *DeploymentValues) WithoutSecrets() (*DeploymentValues, error) {
// This only works with string values for now.
switch v := opt.Value.(type) {
case *clibase.String:
case *clibase.String, *clibase.StringArray:
err := v.Set("")
if err != nil {
panic(err)

View File

@ -57,6 +57,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) {
"SCIM API Key": {
yaml: true,
},
"External Token Encryption Keys": {
yaml: true,
},
// These complex objects should be configured through YAML.
"Support Links": {
flag: true,

173
docs/admin/encryption.md Normal file
View File

@ -0,0 +1,173 @@
# Database Encryption
By default, Coder stores external user tokens in plaintext in the database.
Database Encryption allows Coder administrators to encrypt these tokens at-rest,
preventing attackers with database access from using them to impersonate users.
## How it works
Coder allows administrators to specify
[external token encryption keys](../cli/server.md#external-token-encryption-keys).
If configured, Coder will use these keys to encrypt external user tokens before
storing them in the database. The encryption algorithm used is AES-256-GCM with
a 32-byte key length.
Coder will use the first key provided for both encryption and decryption. If
additional keys are provided, Coder will use it for decryption only. This allows
administrators to rotate encryption keys without invalidating existing tokens.
The following database fields are currently encrypted:
- `user_links.oauth_access_token`
- `user_links.oauth_refresh_token`
- `git_auth_links.oauth_access_token`
- `git_auth_links.oauth_refresh_token`
Additional database fields may be encrypted in the future.
> Implementation notes: each encrypted database column `$C` has a corresponding
> `$C_key_id` column. This column is used to determine which encryption key was
> used to encrypt the data. This allows Coder to rotate encryption keys without
> invalidating existing tokens, and provides referential integrity for encrypted
> data.
>
> The `$C_key_id` column stores the first 7 bytes of the SHA-256 hash of the
> encryption key used to encrypt the data.
>
> Encryption keys in use are stored in `dbcrypt_keys`. This table stores a
> record of all encryption keys that have been used to encrypt data. Active keys
> have a null `revoked_key_id` column, and revoked keys have a non-null
> `revoked_key_id` column. You cannot revoke a key until you have rotated all
> values using that key to a new key.
## Enabling encryption
1. Ensure you have a valid backup of your database. **Do not skip this step.**
If you are using the built-in PostgreSQL database, you can run
[`coder server postgres-builtin-url`](../cli/server_postgres-builtin-url.md)
to get the connection URL.
1. Generate a 32-byte random key and base64-encode it. For example:
```shell
dd if=/dev/urandom bs=32 count=1 | base64
```
1. Store this key in a secure location (for example, a Kubernetes secret):
```shell
kubectl create secret generic coder-external-token-encryption-keys --from-literal=keys=<key>
```
1. In your Coder configuration set `CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS` to a
comma-separated list of base64-encoded keys. For example, in your Helm
`values.yaml`:
```yaml
coder:
env:
[...]
- name: CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS
valueFrom:
secretKeyRef:
name: coder-external-token-encryption-keys
key: keys
```
1. Restart the Coder server. The server will now encrypt all new data with the
provided key.
## Rotating keys
We recommend only having one active encryption key at a time normally. However,
if you need to rotate keys, you can perform the following procedure:
1. Ensure you have a valid backup of your database. **Do not skip this step.**
1. Generate a new encryption key following the same procedure as above.
1. Add the above key to the list of
[external token encryption keys](../cli/server.md#external-token-encryption-keys).
**The new key must appear first in the list**. For example, in the Kubernetes
secret created above:
```yaml
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: coder-external-token-encryption-keys
namespace: coder-namespace
data:
keys: <new-key>,<old-key1>,<old-key2>,...
```
1. After updating the configuration, restart the Coder server. The server will
now encrypt all new data with the new key, but will be able to decrypt tokens
encrypted with the old key(s).
1. To re-encrypt all encrypted database fields with the new key, run
[`coder server dbcrypt rotate`](../cli/server_dbcrypt_rotate.md). This
command will re-encrypt all tokens with the specified new encryption key. We
recommend performing this action during a maintenance window.
> Note: this command requires direct access to the database. If you are using
> the built-in PostgreSQL database, you can run
> [`coder server postgres-builtin-url`](../cli/server_postgres-builtin-url.md)
> to get the connection URL.
1. Once the above command completes successfully, remove the old encryption key
from Coder's configuration and restart Coder once more. You can now safely
delete the old key from your secret store.
## Disabling encryption
To disable encryption, perform the following actions:
1. Ensure you have a valid backup of your database. **Do not skip this step.**
1. Stop all active coderd instances. This will prevent new encrypted data from
being written.
1. Run [`coder server dbcrypt decrypt`](../cli/server_dbcrypt_decrypt.md). This
command will decrypt all encrypted user tokens and revoke all active
encryption keys.
1. Remove all
[external token encryption keys](../cli/server.md#external-token-encryption-keys)
from Coder's configuration.
1. Start coderd. You can now safely delete the encryption keys from your secret
store.
## Deleting Encrypted Data
> NOTE: This is a destructive operation.
To delete all encrypted data from your database, perform the following actions:
1. Ensure you have a valid backup of your database. **Do not skip this step.**
1. Stop all active coderd instances. This will prevent new encrypted data from
being written.
1. Run [`coder server dbcrypt delete`](../cli/server_dbcrypt_delete.md). This
command will delete all encrypted user tokens and revoke all active
encryption keys.
1. Remove all
[external token encryption keys](../cli/server.md#external-token-encryption-keys)
from Coder's configuration.
1. Start coderd. You can now safely delete the encryption keys from your secret
store.
## Troubleshooting
- If Coder detects that the data stored in the database was not encrypted with
any known keys, it will refuse to start. If you are seeing this behaviour,
ensure that the encryption keys provided are correct.
- If Coder detects that the data stored in the database was encrypted with a key
that is no longer active, it will refuse to start. If you are seeing this
behaviour, ensure that the encryption keys provided are correct and that you
have not revoked any keys that are still in use.

1
docs/api/general.md generated
View File

@ -212,6 +212,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
},
"enable_terraform_debug_mode": true,
"experiments": ["string"],
"external_token_encryption_keys": ["string"],
"git_auth": {
"value": [
{

3
docs/api/schemas.md generated
View File

@ -2036,6 +2036,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
},
"enable_terraform_debug_mode": true,
"experiments": ["string"],
"external_token_encryption_keys": ["string"],
"git_auth": {
"value": [
{
@ -2400,6 +2401,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
},
"enable_terraform_debug_mode": true,
"experiments": ["string"],
"external_token_encryption_keys": ["string"],
"git_auth": {
"value": [
{
@ -2613,6 +2615,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `docs_url` | [clibase.URL](#clibaseurl) | false | | |
| `enable_terraform_debug_mode` | boolean | false | | |
| `experiments` | array of string | false | | |
| `external_token_encryption_keys` | array of string | false | | |
| `git_auth` | [clibase.Struct-array_codersdk_GitAuthConfig](#clibasestruct-array_codersdk_gitauthconfig) | false | | |
| `http_address` | string | false | | Http address is a string because it may be set to zero to disable. |
| `in_memory_database` | boolean | false | | |

10
docs/cli/server.md generated
View File

@ -15,6 +15,7 @@ coder server [flags]
| Name | Purpose |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| [<code>create-admin-user</code>](./server_create-admin-user.md) | Create a new admin user with the given username, email and password and adds it to every organization. |
| [<code>dbcrypt</code>](./server_dbcrypt.md) | Manage database encryption. |
| [<code>postgres-builtin-serve</code>](./server_postgres-builtin-serve.md) | Run the built-in PostgreSQL deployment. |
| [<code>postgres-builtin-url</code>](./server_postgres-builtin-url.md) | Output the connection URL for the built-in PostgreSQL deployment. |
@ -273,6 +274,15 @@ Expose the swagger endpoint via /swagger.
Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '\*' to opt-in to all available experiments.
### --external-token-encryption-keys
| | |
| ----------- | -------------------------------------------------- |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS</code> |
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. 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. During normal operation it is recommended to only set one key unless you are in the process of rotating keys with the `coder server dbcrypt rotate` command.
### --provisioner-force-cancel-interval
| | |

19
docs/cli/server_dbcrypt.md generated Normal file
View File

@ -0,0 +1,19 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# server dbcrypt
Manage database encryption.
## Usage
```console
coder server dbcrypt
```
## Subcommands
| Name | Purpose |
| --------------------------------------------------- | ----------------------------------------------------------------------------- |
| [<code>decrypt</code>](./server_dbcrypt_decrypt.md) | Decrypt a previously encrypted database. |
| [<code>delete</code>](./server_dbcrypt_delete.md) | Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION. |
| [<code>rotate</code>](./server_dbcrypt_rotate.md) | Rotate database encryption keys. |

39
docs/cli/server_dbcrypt_decrypt.md generated Normal file
View File

@ -0,0 +1,39 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# server dbcrypt decrypt
Decrypt a previously encrypted database.
## Usage
```console
coder server dbcrypt decrypt [flags]
```
## Options
### --keys
| | |
| ----------- | ---------------------------------------------------------- |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_EXTERNAL_TOKEN_ENCRYPTION_DECRYPT_KEYS</code> |
Keys required to decrypt existing data. Must be a comma-separated list of base64-encoded keys.
### --postgres-url
| | |
| ----------- | ------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_PG_CONNECTION_URL</code> |
The connection URL for the Postgres database.
### -y, --yes
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
Bypass prompts.

34
docs/cli/server_dbcrypt_delete.md generated Normal file
View File

@ -0,0 +1,34 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# server dbcrypt delete
Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.
Aliases:
- rm
## Usage
```console
coder server dbcrypt delete [flags]
```
## Options
### --postgres-url
| | |
| ----------- | ---------------------------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_EXTERNAL_TOKEN_ENCRYPTION_POSTGRES_URL</code> |
The connection URL for the Postgres database.
### -y, --yes
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
Bypass prompts.

48
docs/cli/server_dbcrypt_rotate.md generated Normal file
View File

@ -0,0 +1,48 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# server dbcrypt rotate
Rotate database encryption keys.
## Usage
```console
coder server dbcrypt rotate [flags]
```
## Options
### --new-key
| | |
| ----------- | ------------------------------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_NEW_KEY</code> |
The new external token encryption key. Must be base64-encoded.
### --old-keys
| | |
| ----------- | -------------------------------------------------------------- |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_OLD_KEYS</code> |
The old external token encryption keys. Must be a comma-separated list of base64-encoded keys.
### --postgres-url
| | |
| ----------- | ------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_PG_CONNECTION_URL</code> |
The connection URL for the Postgres database.
### -y, --yes
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
Bypass prompts.

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path d="M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@ -390,6 +390,13 @@
"description": "Learn what usage telemetry Coder collects",
"path": "./admin/telemetry.md",
"icon_path": "./images/icons/science.svg"
},
{
"title": "Database Encryption",
"description": "Learn how to encrypt sensitive data at rest in Coder",
"path": "./admin/database-encryption.md",
"icon_path": "./images/icons/lock.svg",
"state": "enterprise"
}
]
},
@ -699,6 +706,26 @@
"description": "Create a new admin user with the given username, email and password and adds it to every organization.",
"path": "cli/server_create-admin-user.md"
},
{
"title": "server dbcrypt",
"description": "Manage database encryption.",
"path": "cli/server_dbcrypt.md"
},
{
"title": "server dbcrypt decrypt",
"description": "Decrypt a previously encrypted database.",
"path": "cli/server_dbcrypt_decrypt.md"
},
{
"title": "server dbcrypt delete",
"description": "Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.",
"path": "cli/server_dbcrypt_delete.md"
},
{
"title": "server dbcrypt rotate",
"description": "Rotate database encryption keys.",
"path": "cli/server_dbcrypt_rotate.md"
},
{
"title": "server postgres-builtin-serve",
"description": "Run the built-in PostgreSQL deployment.",

View File

@ -5,6 +5,7 @@ package cli
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"io"
"net/url"
@ -19,6 +20,7 @@ import (
"github.com/coder/coder/v2/enterprise/audit/backends"
"github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/coderd/dormancy"
"github.com/coder/coder/v2/enterprise/dbcrypt"
"github.com/coder/coder/v2/enterprise/trialer"
"github.com/coder/coder/v2/tailnet"
@ -74,11 +76,31 @@ func (r *RootCmd) Server(_ func()) *clibase.Cmd {
CheckInactiveUsersCancelFunc: dormancy.CheckInactiveUsers(ctx, options.Logger, options.Database),
}
if encKeys := options.DeploymentValues.ExternalTokenEncryptionKeys.Value(); len(encKeys) != 0 {
keys := make([][]byte, 0, len(encKeys))
for idx, ek := range encKeys {
dk, err := base64.StdEncoding.DecodeString(ek)
if err != nil {
return nil, nil, xerrors.Errorf("decode external-token-encryption-key %d: %w", idx, err)
}
keys = append(keys, dk)
}
cs, err := dbcrypt.NewCiphers(keys...)
if err != nil {
return nil, nil, xerrors.Errorf("initialize encryption: %w", err)
}
o.ExternalTokenEncryption = cs
}
api, err := coderd.New(ctx, o)
if err != nil {
return nil, nil, err
}
return api.AGPL, api, nil
})
cmd.AddSubcommands(
r.dbcryptCmd(),
)
return cmd
}

View File

@ -0,0 +1,344 @@
//go:build !slim
package cli
import (
"context"
"encoding/base64"
"fmt"
"strings"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/enterprise/dbcrypt"
"golang.org/x/xerrors"
)
func (r *RootCmd) dbcryptCmd() *clibase.Cmd {
dbcryptCmd := &clibase.Cmd{
Use: "dbcrypt",
Short: "Manage database encryption.",
Handler: func(inv *clibase.Invocation) error {
return inv.Command.HelpHandler(inv)
},
}
dbcryptCmd.AddSubcommands(
r.dbcryptDecryptCmd(),
r.dbcryptDeleteCmd(),
r.dbcryptRotateCmd(),
)
return dbcryptCmd
}
func (*RootCmd) dbcryptRotateCmd() *clibase.Cmd {
var flags rotateFlags
cmd := &clibase.Cmd{
Use: "rotate",
Short: "Rotate database encryption keys.",
Handler: func(inv *clibase.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
logger := slog.Make(sloghuman.Sink(inv.Stdout))
if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok {
logger = logger.Leveled(slog.LevelDebug)
}
if err := flags.valid(); err != nil {
return err
}
ks := [][]byte{}
dk, err := base64.StdEncoding.DecodeString(flags.New)
if err != nil {
return xerrors.Errorf("decode new key: %w", err)
}
ks = append(ks, dk)
for _, k := range flags.Old {
dk, err := base64.StdEncoding.DecodeString(k)
if err != nil {
return xerrors.Errorf("decode old key: %w", err)
}
ks = append(ks, dk)
}
ciphers, err := dbcrypt.NewCiphers(ks...)
if err != nil {
return xerrors.Errorf("create ciphers: %w", err)
}
var act string
switch len(flags.Old) {
case 0:
act = "Data will be encrypted with the new key."
default:
act = "Data will be decrypted with all available keys and re-encrypted with new key."
}
msg := fmt.Sprintf("%s\n\n- New key: %s\n- Old keys: %s\n\nRotate external token encryption keys?\n",
act,
flags.New,
strings.Join(flags.Old, ", "),
)
if _, err := cliui.Prompt(inv, cliui.PromptOptions{Text: msg, IsConfirm: true}); err != nil {
return err
}
sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL)
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}
defer func() {
_ = sqlDB.Close()
}()
logger.Info(ctx, "connected to postgres")
if err := dbcrypt.Rotate(ctx, logger, sqlDB, ciphers); err != nil {
return xerrors.Errorf("rotate ciphers: %w", err)
}
logger.Info(ctx, "operation completed successfully")
return nil
},
}
flags.attach(&cmd.Options)
return cmd
}
func (*RootCmd) dbcryptDecryptCmd() *clibase.Cmd {
var flags decryptFlags
cmd := &clibase.Cmd{
Use: "decrypt",
Short: "Decrypt a previously encrypted database.",
Handler: func(inv *clibase.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
logger := slog.Make(sloghuman.Sink(inv.Stdout))
if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok {
logger = logger.Leveled(slog.LevelDebug)
}
if err := flags.valid(); err != nil {
return err
}
ks := make([][]byte, 0, len(flags.Keys))
for _, k := range flags.Keys {
dk, err := base64.StdEncoding.DecodeString(k)
if err != nil {
return xerrors.Errorf("decode key: %w", err)
}
ks = append(ks, dk)
}
ciphers, err := dbcrypt.NewCiphers(ks...)
if err != nil {
return xerrors.Errorf("create ciphers: %w", err)
}
if _, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "This will decrypt all encrypted data in the database. Are you sure you want to continue?",
IsConfirm: true,
}); err != nil {
return err
}
sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL)
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}
defer func() {
_ = sqlDB.Close()
}()
logger.Info(ctx, "connected to postgres")
if err := dbcrypt.Decrypt(ctx, logger, sqlDB, ciphers); err != nil {
return xerrors.Errorf("rotate ciphers: %w", err)
}
logger.Info(ctx, "operation completed successfully")
return nil
},
}
flags.attach(&cmd.Options)
return cmd
}
func (*RootCmd) dbcryptDeleteCmd() *clibase.Cmd {
var flags deleteFlags
cmd := &clibase.Cmd{
Use: "delete",
Short: "Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.",
Handler: func(inv *clibase.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
logger := slog.Make(sloghuman.Sink(inv.Stdout))
if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok {
logger = logger.Leveled(slog.LevelDebug)
}
if err := flags.valid(); err != nil {
return err
}
msg := `All encrypted data will be deleted from the database:
- Encrypted user OAuth access and refresh tokens
- Encrypted user Git authentication access and refresh tokens
Are you sure you want to continue?`
if _, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: msg,
IsConfirm: true,
}); err != nil {
return err
}
sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL)
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}
defer func() {
_ = sqlDB.Close()
}()
logger.Info(ctx, "connected to postgres")
if err := dbcrypt.Delete(ctx, logger, sqlDB); err != nil {
return xerrors.Errorf("delete encrypted data: %w", err)
}
logger.Info(ctx, "operation completed successfully")
return nil
},
}
flags.attach(&cmd.Options)
return cmd
}
type rotateFlags struct {
PostgresURL string
New string
Old []string
}
func (f *rotateFlags) attach(opts *clibase.OptionSet) {
*opts = append(
*opts,
clibase.Option{
Flag: "postgres-url",
Env: "CODER_PG_CONNECTION_URL",
Description: "The connection URL for the Postgres database.",
Value: clibase.StringOf(&f.PostgresURL),
},
clibase.Option{
Flag: "new-key",
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_NEW_KEY",
Description: "The new external token encryption key. Must be base64-encoded.",
Value: clibase.StringOf(&f.New),
},
clibase.Option{
Flag: "old-keys",
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_OLD_KEYS",
Description: "The old external token encryption keys. Must be a comma-separated list of base64-encoded keys.",
Value: clibase.StringArrayOf(&f.Old),
},
cliui.SkipPromptOption(),
)
}
func (f *rotateFlags) valid() error {
if f.PostgresURL == "" {
return xerrors.Errorf("no database configured")
}
if f.New == "" {
return xerrors.Errorf("no new key provided")
}
if val, err := base64.StdEncoding.DecodeString(f.New); err != nil {
return xerrors.Errorf("new key must be base64-encoded")
} else if len(val) != 32 {
return xerrors.Errorf("new key must be exactly 32 bytes in length")
}
for i, k := range f.Old {
if val, err := base64.StdEncoding.DecodeString(k); err != nil {
return xerrors.Errorf("old key at index %d must be base64-encoded", i)
} else if len(val) != 32 {
return xerrors.Errorf("old key at index %d must be exactly 32 bytes in length", i)
}
// Pedantic, but typos here will ruin your day.
if k == f.New {
return xerrors.Errorf("old key at index %d is the same as the new key", i)
}
}
return nil
}
type decryptFlags struct {
PostgresURL string
Keys []string
}
func (f *decryptFlags) attach(opts *clibase.OptionSet) {
*opts = append(
*opts,
clibase.Option{
Flag: "postgres-url",
Env: "CODER_PG_CONNECTION_URL",
Description: "The connection URL for the Postgres database.",
Value: clibase.StringOf(&f.PostgresURL),
},
clibase.Option{
Flag: "keys",
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_DECRYPT_KEYS",
Description: "Keys required to decrypt existing data. Must be a comma-separated list of base64-encoded keys.",
Value: clibase.StringArrayOf(&f.Keys),
},
cliui.SkipPromptOption(),
)
}
func (f *decryptFlags) valid() error {
if f.PostgresURL == "" {
return xerrors.Errorf("no database configured")
}
if len(f.Keys) == 0 {
return xerrors.Errorf("no keys provided")
}
for i, k := range f.Keys {
if val, err := base64.StdEncoding.DecodeString(k); err != nil {
return xerrors.Errorf("key at index %d must be base64-encoded", i)
} else if len(val) != 32 {
return xerrors.Errorf("key at index %d must be exactly 32 bytes in length", i)
}
}
return nil
}
type deleteFlags struct {
PostgresURL string
Confirm bool
}
func (f *deleteFlags) attach(opts *clibase.OptionSet) {
*opts = append(
*opts,
clibase.Option{
Flag: "postgres-url",
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_POSTGRES_URL",
Description: "The connection URL for the Postgres database.",
Value: clibase.StringOf(&f.PostgresURL),
},
cliui.SkipPromptOption(),
)
}
func (f *deleteFlags) valid() error {
if f.PostgresURL == "" {
return xerrors.Errorf("no database configured")
}
return nil
}

View File

@ -0,0 +1,277 @@
package cli_test
import (
"context"
"database/sql"
"encoding/base64"
"testing"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"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/database/postgres"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/enterprise/dbcrypt"
"github.com/coder/coder/v2/pty/ptytest"
)
// nolint: paralleltest // use of t.Setenv
func TestServerDBCrypt(t *testing.T) {
if !dbtestutil.WillUsePostgres() {
t.Skip("this test requires a postgres instance")
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
// Setup a postgres database.
connectionURL, closePg, err := postgres.Open()
require.NoError(t, err)
t.Cleanup(closePg)
sqlDB, err := sql.Open("postgres", connectionURL)
require.NoError(t, err)
t.Cleanup(func() {
_ = sqlDB.Close()
})
db := database.New(sqlDB)
// Populate the database with some unencrypted data.
users := genData(t, db, 10)
// Setup an initial cipher
keyA := mustString(t, 32)
cipherA, err := dbcrypt.NewCiphers([]byte(keyA))
require.NoError(t, err)
// Encrypt all the data with the initial cipher.
inv, _ := newCLI(t, "server", "dbcrypt", "rotate",
"--postgres-url", connectionURL,
"--new-key", base64.StdEncoding.EncodeToString([]byte(keyA)),
"--yes",
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
err = inv.Run()
require.NoError(t, err)
// Validate that all existing data has been encrypted with cipher A.
for _, usr := range users {
requireEncryptedWithCipher(ctx, t, db, cipherA[0], usr.ID)
}
// Create an encrypted database
cryptdb, err := dbcrypt.New(ctx, db, cipherA...)
require.NoError(t, err)
// Populate the database with some encrypted data using cipher A.
users = append(users, genData(t, cryptdb, 10)...)
// Re-encrypt all existing data with a new cipher.
keyB := mustString(t, 32)
cipherBA, err := dbcrypt.NewCiphers([]byte(keyB), []byte(keyA))
require.NoError(t, err)
inv, _ = newCLI(t, "server", "dbcrypt", "rotate",
"--postgres-url", connectionURL,
"--new-key", base64.StdEncoding.EncodeToString([]byte(keyB)),
"--old-keys", base64.StdEncoding.EncodeToString([]byte(keyA)),
"--yes",
)
pty = ptytest.New(t)
inv.Stdout = pty.Output()
err = inv.Run()
require.NoError(t, err)
// Validate that all data has been re-encrypted with cipher B.
for _, usr := range users {
requireEncryptedWithCipher(ctx, t, db, cipherBA[0], usr.ID)
}
// Assert that we can revoke the old key.
err = db.RevokeDBCryptKey(ctx, cipherA[0].HexDigest())
require.NoError(t, err, "failed to revoke old key")
// Assert that the key has been revoked in the database.
keys, err := db.GetDBCryptKeys(ctx)
oldKey := keys[0] // ORDER BY number ASC;
newKey := keys[1]
require.NoError(t, err, "failed to get db crypt keys")
require.Len(t, keys, 2, "expected exactly 2 keys")
require.Equal(t, cipherBA[0].HexDigest(), newKey.ActiveKeyDigest.String, "expected the new key to be the active key")
require.Empty(t, newKey.RevokedKeyDigest.String, "expected the new key to not be revoked")
require.Equal(t, cipherBA[1].HexDigest(), oldKey.RevokedKeyDigest.String, "expected the old key to be revoked")
require.Empty(t, oldKey.ActiveKeyDigest.String, "expected the old key to not be active")
// Revoking the new key should fail.
err = db.RevokeDBCryptKey(ctx, cipherBA[0].HexDigest())
require.Error(t, err, "expected to fail to revoke the new key")
var pgErr *pq.Error
require.True(t, xerrors.As(err, &pgErr), "expected a pg error")
require.EqualValues(t, "23503", pgErr.Code, "expected a foreign key constraint violation error")
// Decrypt the data using only cipher B. This should result in the key being revoked.
inv, _ = newCLI(t, "server", "dbcrypt", "decrypt",
"--postgres-url", connectionURL,
"--keys", base64.StdEncoding.EncodeToString([]byte(keyB)),
"--yes",
)
pty = ptytest.New(t)
inv.Stdout = pty.Output()
err = inv.Run()
require.NoError(t, err)
// Validate that both keys have been revoked.
keys, err = db.GetDBCryptKeys(ctx)
require.NoError(t, err, "failed to get db crypt keys")
require.Len(t, keys, 2, "expected exactly 2 keys")
for _, key := range keys {
require.Empty(t, key.ActiveKeyDigest.String, "expected the new key to not be active")
}
// Validate that all data has been decrypted.
for _, usr := range users {
requireEncryptedWithCipher(ctx, t, db, &nullCipher{}, usr.ID)
}
// Re-encrypt all existing data with a new cipher.
keyC := mustString(t, 32)
cipherC, err := dbcrypt.NewCiphers([]byte(keyC))
require.NoError(t, err)
inv, _ = newCLI(t, "server", "dbcrypt", "rotate",
"--postgres-url", connectionURL,
"--new-key", base64.StdEncoding.EncodeToString([]byte(keyC)),
"--yes",
)
pty = ptytest.New(t)
inv.Stdout = pty.Output()
err = inv.Run()
require.NoError(t, err)
// Validate that all data has been re-encrypted with cipher C.
for _, usr := range users {
requireEncryptedWithCipher(ctx, t, db, cipherC[0], usr.ID)
}
// Now delete all the encrypted data.
inv, _ = newCLI(t, "server", "dbcrypt", "delete",
"--postgres-url", connectionURL,
"--external-token-encryption-keys", base64.StdEncoding.EncodeToString([]byte(keyC)),
"--yes",
)
pty = ptytest.New(t)
inv.Stdout = pty.Output()
err = inv.Run()
require.NoError(t, err)
// Assert that no user links remain.
for _, usr := range users {
userLinks, err := db.GetUserLinksByUserID(ctx, usr.ID)
require.NoError(t, err, "failed to get user links for user %s", usr.ID)
require.Empty(t, userLinks)
gitAuthLinks, err := db.GetGitAuthLinksByUserID(ctx, usr.ID)
require.NoError(t, err, "failed to get git auth links for user %s", usr.ID)
require.Empty(t, gitAuthLinks)
}
// Validate that the key has been revoked in the database.
keys, err = db.GetDBCryptKeys(ctx)
require.NoError(t, err, "failed to get db crypt keys")
require.Len(t, keys, 3, "expected exactly 3 keys")
for _, k := range keys {
require.Empty(t, k.ActiveKeyDigest.String, "expected the key to not be active")
require.NotEmpty(t, k.RevokedKeyDigest.String, "expected the key to be revoked")
}
}
func genData(t *testing.T, db database.Store, n int) []database.User {
t.Helper()
var users []database.User
for i := 0; i < n; i++ {
usr := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
_ = dbgen.UserLink(t, db, database.UserLink{
UserID: usr.ID,
LoginType: usr.LoginType,
OAuthAccessToken: "access-" + usr.ID.String(),
OAuthRefreshToken: "refresh-" + usr.ID.String(),
})
_ = dbgen.GitAuthLink(t, db, database.GitAuthLink{
UserID: usr.ID,
ProviderID: "fake",
OAuthAccessToken: "access-" + usr.ID.String(),
OAuthRefreshToken: "refresh-" + usr.ID.String(),
})
users = append(users, usr)
}
return users
}
func mustString(t *testing.T, n int) string {
t.Helper()
s, err := cryptorand.String(n)
require.NoError(t, err)
return s
}
func requireEncryptedEquals(t *testing.T, c dbcrypt.Cipher, expected, actual string) {
t.Helper()
var decodedVal []byte
var err error
if _, ok := c.(*nullCipher); !ok {
decodedVal, err = base64.StdEncoding.DecodeString(actual)
require.NoError(t, err, "failed to decode base64 string")
} else {
// If a nullCipher is being used, we expect the value not to be encrypted.
decodedVal = []byte(actual)
}
val, err := c.Decrypt(decodedVal)
require.NoError(t, err, "failed to decrypt value")
require.Equal(t, expected, string(val))
}
func requireEncryptedWithCipher(ctx context.Context, t *testing.T, db database.Store, c dbcrypt.Cipher, userID uuid.UUID) {
t.Helper()
userLinks, err := db.GetUserLinksByUserID(ctx, userID)
require.NoError(t, err, "failed to get user links for user %s", userID)
for _, ul := range userLinks {
requireEncryptedEquals(t, c, "access-"+userID.String(), ul.OAuthAccessToken)
requireEncryptedEquals(t, c, "refresh-"+userID.String(), ul.OAuthRefreshToken)
require.Equal(t, c.HexDigest(), ul.OAuthAccessTokenKeyID.String)
require.Equal(t, c.HexDigest(), ul.OAuthRefreshTokenKeyID.String)
}
gitAuthLinks, err := db.GetGitAuthLinksByUserID(ctx, userID)
require.NoError(t, err, "failed to get git auth links for user %s", userID)
for _, gal := range gitAuthLinks {
requireEncryptedEquals(t, c, "access-"+userID.String(), gal.OAuthAccessToken)
requireEncryptedEquals(t, c, "refresh-"+userID.String(), gal.OAuthRefreshToken)
require.Equal(t, c.HexDigest(), gal.OAuthAccessTokenKeyID.String)
require.Equal(t, c.HexDigest(), gal.OAuthRefreshTokenKeyID.String)
}
}
// nullCipher is a dbcrypt.Cipher that does not encrypt or decrypt.
// used for testing
type nullCipher struct{}
func (*nullCipher) Encrypt(b []byte) ([]byte, error) {
return b, nil
}
func (*nullCipher) Decrypt(b []byte) ([]byte, error) {
return b, nil
}
func (*nullCipher) HexDigest() string {
return "" // This co-incidentally happens to be the value of sql.NullString{}.String...
}
var _ dbcrypt.Cipher = (*nullCipher)(nil)

View File

@ -6,6 +6,7 @@ Start a Coder server
create-admin-user Create a new admin user with the given username,
email and password and adds it to every
organization.
dbcrypt Manage database encryption.
postgres-builtin-serve Run the built-in PostgreSQL deployment.
postgres-builtin-url Output the connection URL for the built-in
PostgreSQL deployment.
@ -458,6 +459,16 @@ These options are only available in the Enterprise Edition.
An HTTP URL that is accessible by other replicas to relay DERP
traffic. Required for high availability.
--external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS
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. 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. During normal
operation it is recommended to only set one key unless you are in the
process of rotating keys with the `coder server dbcrypt rotate`
command.
--scim-auth-header string, $CODER_SCIM_AUTH_HEADER
Enables SCIM and sets the authentication header for the built-in SCIM
server. New users are automatically created with OIDC authentication.

View File

@ -0,0 +1,12 @@
Usage: coder server dbcrypt
Manage database encryption.
Subcommands
decrypt Decrypt a previously encrypted database.
delete Delete all encrypted data from the database. THIS IS A
DESTRUCTIVE OPERATION.
rotate Rotate database encryption keys.
---
Run `coder --help` for a list of global options.

View File

@ -0,0 +1,17 @@
Usage: coder server dbcrypt decrypt [flags]
Decrypt a previously encrypted database.
Options
--keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_DECRYPT_KEYS
Keys required to decrypt existing data. Must be a comma-separated list
of base64-encoded keys.
--postgres-url string, $CODER_PG_CONNECTION_URL
The connection URL for the Postgres database.
-y, --yes bool
Bypass prompts.
---
Run `coder --help` for a list of global options.

View File

@ -0,0 +1,15 @@
Usage: coder server dbcrypt delete [flags]
Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.
Aliases: rm
Options
--postgres-url string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_POSTGRES_URL
The connection URL for the Postgres database.
-y, --yes bool
Bypass prompts.
---
Run `coder --help` for a list of global options.

View File

@ -0,0 +1,20 @@
Usage: coder server dbcrypt rotate [flags]
Rotate database encryption keys.
Options
--new-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_NEW_KEY
The new external token encryption key. Must be base64-encoded.
--old-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_OLD_KEYS
The old external token encryption keys. Must be a comma-separated list
of base64-encoded keys.
--postgres-url string, $CODER_PG_CONNECTION_URL
The connection URL for the Postgres database.
-y, --yes bool
Bypass prompts.
---
Run `coder --help` for a list of global options.

View File

@ -33,6 +33,7 @@ import (
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/enterprise/coderd/proxyhealth"
"github.com/coder/coder/v2/enterprise/coderd/schedule"
"github.com/coder/coder/v2/enterprise/dbcrypt"
"github.com/coder/coder/v2/enterprise/derpmesh"
"github.com/coder/coder/v2/enterprise/replicasync"
"github.com/coder/coder/v2/enterprise/tailnet"
@ -47,8 +48,8 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
if options.EntitlementsUpdateInterval == 0 {
options.EntitlementsUpdateInterval = 10 * time.Minute
}
if options.Keys == nil {
options.Keys = Keys
if options.LicenseKeys == nil {
options.LicenseKeys = Keys
}
if options.Options == nil {
options.Options = &coderd.Options{}
@ -61,10 +62,38 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
}
ctx, cancelFunc := context.WithCancel(ctx)
api := &API{
ctx: ctx,
cancel: cancelFunc,
if options.ExternalTokenEncryption == nil {
options.ExternalTokenEncryption = make([]dbcrypt.Cipher, 0)
}
// Database encryption is an enterprise feature, but as checking license entitlements
// depends on the database, we end up in a chicken-and-egg situation. To avoid this,
// we always enable it but only soft-enforce it.
if len(options.ExternalTokenEncryption) > 0 {
var keyDigests []string
for _, cipher := range options.ExternalTokenEncryption {
keyDigests = append(keyDigests, cipher.HexDigest())
}
options.Logger.Info(ctx, "database encryption enabled", slog.F("keys", keyDigests))
}
cryptDB, err := dbcrypt.New(ctx, options.Database, options.ExternalTokenEncryption...)
if err != nil {
cancelFunc()
// If we fail to initialize the database, it's likely that the
// database is encrypted with an unknown external token encryption key.
// This is a fatal error.
var derr *dbcrypt.DecryptFailedError
if xerrors.As(err, &derr) {
return nil, xerrors.Errorf("database encrypted with unknown key, either add the key or see https://coder.com/docs/v2/latest/admin/encryption#disabling-encryption: %w", derr)
}
return nil, xerrors.Errorf("init database encryption: %w", err)
}
options.Database = cryptDB
api := &API{
ctx: ctx,
cancel: cancelFunc,
AGPL: coderd.New(options.Options),
Options: options,
provisionerDaemonAuth: &provisionerDaemonAuth{
@ -364,6 +393,8 @@ type Options struct {
BrowserOnly bool
SCIMAPIKey []byte
ExternalTokenEncryption []dbcrypt.Cipher
// Used for high availability.
ReplicaSyncUpdateInterval time.Duration
DERPServerRelayAddress string
@ -374,7 +405,7 @@ type Options struct {
EntitlementsUpdateInterval time.Duration
ProxyHealthInterval time.Duration
Keys map[string]ed25519.PublicKey
LicenseKeys map[string]ed25519.PublicKey
// optional pre-shared key for authentication of external provisioner daemons
ProvisionerDaemonPSK string
@ -429,13 +460,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
entitlements, err := license.Entitlements(
ctx, api.Database,
api.Logger, len(api.replicaManager.AllPrimary()), len(api.GitAuthConfigs), api.Keys, map[codersdk.FeatureName]bool{
api.Logger, len(api.replicaManager.AllPrimary()), len(api.GitAuthConfigs), api.LicenseKeys, map[codersdk.FeatureName]bool{
codersdk.FeatureAuditLog: api.AuditLogging,
codersdk.FeatureBrowserOnly: api.BrowserOnly,
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "",
codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1,
codersdk.FeatureTemplateRBAC: api.RBAC,
codersdk.FeatureExternalTokenEncryption: len(api.ExternalTokenEncryption) > 0,
codersdk.FeatureExternalProvisionerDaemons: true,
codersdk.FeatureAdvancedTemplateScheduling: true,
// FeatureTemplateAutostopRequirement depends on
@ -615,6 +647,16 @@ func (api *API) updateEntitlements(ctx context.Context) error {
}
}
// External token encryption is soft-enforced
featureExternalTokenEncryption := entitlements.Features[codersdk.FeatureExternalTokenEncryption]
featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0
if featureExternalTokenEncryption.Enabled && featureExternalTokenEncryption.Entitlement != codersdk.EntitlementEntitled {
msg := fmt.Sprintf("%s is enabled (due to setting external token encryption keys) but your license is not entitled to this feature.", codersdk.FeatureExternalTokenEncryption.Humanize())
api.Logger.Warn(ctx, msg)
entitlements.Warnings = append(entitlements.Warnings, msg)
}
entitlements.Features[codersdk.FeatureExternalTokenEncryption] = featureExternalTokenEncryption
api.entitlementsMu.Lock()
defer api.entitlementsMu.Unlock()
api.entitlements = entitlements

View File

@ -1,8 +1,10 @@
package coderd_test
import (
"bytes"
"context"
"reflect"
"strings"
"testing"
"time"
@ -16,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
@ -23,6 +26,7 @@ import (
"github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/enterprise/dbcrypt"
"github.com/coder/coder/v2/testutil"
)
@ -48,25 +52,27 @@ func TestEntitlements(t *testing.T) {
AuditLogging: true,
DontAddLicense: true,
})
// Enable all features
features := make(license.Features)
for _, feature := range codersdk.FeatureNames {
features[feature] = 1
}
features[codersdk.FeatureUserLimit] = 100
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureExternalProvisionerDaemons: 1,
codersdk.FeatureAdvancedTemplateScheduling: 1,
codersdk.FeatureWorkspaceProxy: 1,
codersdk.FeatureUserRoleManagement: 1,
},
GraceAt: time.Now().Add(59 * 24 * time.Hour),
Features: features,
GraceAt: time.Now().Add(59 * 24 * time.Hour),
})
res, err := client.Entitlements(context.Background())
require.NoError(t, err)
assert.True(t, res.HasLicense)
ul := res.Features[codersdk.FeatureUserLimit]
assert.Equal(t, codersdk.EntitlementEntitled, ul.Entitlement)
assert.Equal(t, int64(100), *ul.Limit)
assert.Equal(t, int64(1), *ul.Actual)
if assert.NotNil(t, ul.Limit) {
assert.Equal(t, int64(100), *ul.Limit)
}
if assert.NotNil(t, ul.Actual) {
assert.Equal(t, int64(1), *ul.Actual)
}
assert.True(t, ul.Enabled)
al := res.Features[codersdk.FeatureAuditLog]
assert.Equal(t, codersdk.EntitlementEntitled, al.Entitlement)
@ -228,6 +234,134 @@ func TestAuditLogging(t *testing.T) {
})
}
func TestExternalTokenEncryption(t *testing.T) {
t.Parallel()
t.Run("Enabled", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
ciphers, err := dbcrypt.NewCiphers(bytes.Repeat([]byte("a"), 32))
require.NoError(t, err)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
EntitlementsUpdateInterval: 25 * time.Millisecond,
ExternalTokenEncryption: ciphers,
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureExternalTokenEncryption: 1,
},
},
Options: &coderdtest.Options{
Database: db,
Pubsub: ps,
},
})
keys, err := db.GetDBCryptKeys(ctx)
require.NoError(t, err)
require.Len(t, keys, 1)
require.Equal(t, ciphers[0].HexDigest(), keys[0].ActiveKeyDigest.String)
require.Eventually(t, func() bool {
entitlements, err := client.Entitlements(context.Background())
assert.NoError(t, err)
feature := entitlements.Features[codersdk.FeatureExternalTokenEncryption]
entitled := feature.Entitlement == codersdk.EntitlementEntitled
var warningExists bool
for _, warning := range entitlements.Warnings {
if strings.Contains(warning, codersdk.FeatureExternalTokenEncryption.Humanize()) {
warningExists = true
break
}
}
t.Logf("feature: %+v, warnings: %+v, errors: %+v", feature, entitlements.Warnings, entitlements.Errors)
return feature.Enabled && entitled && !warningExists
}, testutil.WaitShort, testutil.IntervalFast)
})
t.Run("Disabled", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
ciphers, err := dbcrypt.NewCiphers()
require.NoError(t, err)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
DontAddLicense: true,
EntitlementsUpdateInterval: 25 * time.Millisecond,
ExternalTokenEncryption: ciphers,
Options: &coderdtest.Options{
Database: db,
Pubsub: ps,
},
})
keys, err := db.GetDBCryptKeys(ctx)
require.NoError(t, err)
require.Empty(t, keys)
require.Eventually(t, func() bool {
entitlements, err := client.Entitlements(context.Background())
assert.NoError(t, err)
feature := entitlements.Features[codersdk.FeatureExternalTokenEncryption]
entitled := feature.Entitlement == codersdk.EntitlementEntitled
var warningExists bool
for _, warning := range entitlements.Warnings {
if strings.Contains(warning, codersdk.FeatureExternalTokenEncryption.Humanize()) {
warningExists = true
break
}
}
t.Logf("feature: %+v, warnings: %+v, errors: %+v", feature, entitlements.Warnings, entitlements.Errors)
return !feature.Enabled && !entitled && !warningExists
}, testutil.WaitShort, testutil.IntervalFast)
})
t.Run("PreviouslyEnabledButMissingFromLicense", func(t *testing.T) {
// If this test fails, it potentially means that a customer who has
// actively been using this feature is now unable _start coderd_
// because of a licensing issue. This should never happen.
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
ciphers, err := dbcrypt.NewCiphers(bytes.Repeat([]byte("a"), 32))
require.NoError(t, err)
dbc, err := dbcrypt.New(ctx, db, ciphers...) // should insert key
require.NoError(t, err)
keys, err := dbc.GetDBCryptKeys(ctx)
require.NoError(t, err)
require.Len(t, keys, 1)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
DontAddLicense: true,
EntitlementsUpdateInterval: 25 * time.Millisecond,
ExternalTokenEncryption: ciphers,
Options: &coderdtest.Options{
Database: db,
Pubsub: ps,
},
})
require.Eventually(t, func() bool {
entitlements, err := client.Entitlements(context.Background())
assert.NoError(t, err)
feature := entitlements.Features[codersdk.FeatureExternalTokenEncryption]
entitled := feature.Entitlement == codersdk.EntitlementEntitled
var warningExists bool
for _, warning := range entitlements.Warnings {
if strings.Contains(warning, codersdk.FeatureExternalTokenEncryption.Humanize()) {
warningExists = true
break
}
}
t.Logf("feature: %+v, warnings: %+v, errors: %+v", feature, entitlements.Warnings, entitlements.Errors)
return feature.Enabled && !entitled && warningExists
}, testutil.WaitShort, testutil.IntervalFast)
})
}
// testDBAuthzRole returns a context with a subject that has a role
// with permissions required for test setup.
func testDBAuthzRole(ctx context.Context) context.Context {

View File

@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/enterprise/dbcrypt"
)
const (
@ -56,6 +57,7 @@ type Options struct {
DontAddLicense bool
DontAddFirstUser bool
ReplicaSyncUpdateInterval time.Duration
ExternalTokenEncryption []dbcrypt.Cipher
ProvisionerDaemonPSK string
}
@ -92,10 +94,11 @@ func NewWithAPI(t *testing.T, options *Options) (
ReplicaSyncUpdateInterval: options.ReplicaSyncUpdateInterval,
Options: oop,
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
Keys: Keys,
LicenseKeys: Keys,
ProxyHealthInterval: options.ProxyHealthInterval,
DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(),
ProvisionerDaemonPSK: options.ProvisionerDaemonPSK,
ExternalTokenEncryption: options.ExternalTokenEncryption,
})
require.NoError(t, err)
setHandler(coderAPI.AGPL.RootHandler)

View File

@ -84,7 +84,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
return
}
rawClaims, err := license.ParseRaw(addLicense.License, api.Keys)
rawClaims, err := license.ParseRaw(addLicense.License, api.LicenseKeys)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid license",
@ -102,7 +102,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
}
expTime := time.Unix(int64(exp), 0)
claims, err := license.ParseClaims(addLicense.License, api.Keys)
claims, err := license.ParseClaims(addLicense.License, api.LicenseKeys)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid license",

View File

@ -0,0 +1,214 @@
package dbcrypt
import (
"context"
"database/sql"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
)
// Rotate rotates the database encryption keys by re-encrypting all user tokens
// with the first cipher and revoking all other ciphers.
func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Cipher) error {
db := database.New(sqlDB)
cryptDB, err := New(ctx, db, ciphers...)
if err != nil {
return xerrors.Errorf("create cryptdb: %w", err)
}
users, err := cryptDB.GetUsers(ctx, database.GetUsersParams{})
if err != nil {
return xerrors.Errorf("get users: %w", err)
}
log.Info(ctx, "encrypting user tokens", slog.F("user_count", len(users)))
for idx, usr := range users {
err := cryptDB.InTx(func(tx database.Store) error {
userLinks, err := tx.GetUserLinksByUserID(ctx, usr.ID)
if err != nil {
return xerrors.Errorf("get user links for user: %w", err)
}
for _, userLink := range userLinks {
if userLink.OAuthAccessTokenKeyID.String == ciphers[0].HexDigest() && userLink.OAuthRefreshTokenKeyID.String == ciphers[0].HexDigest() {
log.Debug(ctx, "skipping user link", slog.F("user_id", usr.ID), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
continue
}
if _, err := tx.UpdateUserLink(ctx, database.UpdateUserLinkParams{
OAuthAccessToken: userLink.OAuthAccessToken,
OAuthRefreshToken: userLink.OAuthRefreshToken,
OAuthExpiry: userLink.OAuthExpiry,
UserID: usr.ID,
LoginType: usr.LoginType,
}); err != nil {
return xerrors.Errorf("update user link user_id=%s linked_id=%s: %w", userLink.UserID, userLink.LinkedID, err)
}
}
gitAuthLinks, err := tx.GetGitAuthLinksByUserID(ctx, usr.ID)
if err != nil {
return xerrors.Errorf("get git auth links for user: %w", err)
}
for _, gitAuthLink := range gitAuthLinks {
if gitAuthLink.OAuthAccessTokenKeyID.String == ciphers[0].HexDigest() && gitAuthLink.OAuthRefreshTokenKeyID.String == ciphers[0].HexDigest() {
log.Debug(ctx, "skipping git auth link", slog.F("user_id", usr.ID), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
continue
}
if _, err := tx.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
ProviderID: gitAuthLink.ProviderID,
UserID: usr.ID,
UpdatedAt: gitAuthLink.UpdatedAt,
OAuthAccessToken: gitAuthLink.OAuthAccessToken,
OAuthRefreshToken: gitAuthLink.OAuthRefreshToken,
OAuthExpiry: gitAuthLink.OAuthExpiry,
}); err != nil {
return xerrors.Errorf("update git auth link user_id=%s provider_id=%s: %w", gitAuthLink.UserID, gitAuthLink.ProviderID, err)
}
}
return nil
}, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
})
if err != nil {
return xerrors.Errorf("update user links: %w", err)
}
log.Debug(ctx, "encrypted user tokens", slog.F("user_id", usr.ID), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
}
// Revoke old keys
for _, c := range ciphers[1:] {
if err := db.RevokeDBCryptKey(ctx, c.HexDigest()); err != nil {
return xerrors.Errorf("revoke key: %w", err)
}
log.Info(ctx, "revoked unused key", slog.F("digest", c.HexDigest()))
}
return nil
}
// Decrypt decrypts all user tokens and revokes all ciphers.
func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Cipher) error {
db := database.New(sqlDB)
cdb, err := New(ctx, db, ciphers...)
if err != nil {
return xerrors.Errorf("create cryptdb: %w", err)
}
// HACK: instead of adding logic to configure the primary cipher, we just
// set it to the empty string so that it will not encrypt anything.
cryptDB, ok := cdb.(*dbCrypt)
if !ok {
return xerrors.Errorf("developer error: dbcrypt.New did not return *dbCrypt")
}
cryptDB.primaryCipherDigest = ""
users, err := cryptDB.GetUsers(ctx, database.GetUsersParams{})
if err != nil {
return xerrors.Errorf("get users: %w", err)
}
log.Info(ctx, "decrypting user tokens", slog.F("user_count", len(users)))
for idx, usr := range users {
err := cryptDB.InTx(func(tx database.Store) error {
userLinks, err := tx.GetUserLinksByUserID(ctx, usr.ID)
if err != nil {
return xerrors.Errorf("get user links for user: %w", err)
}
for _, userLink := range userLinks {
if !userLink.OAuthAccessTokenKeyID.Valid && !userLink.OAuthRefreshTokenKeyID.Valid {
log.Debug(ctx, "skipping user link", slog.F("user_id", usr.ID), slog.F("current", idx+1))
continue
}
if _, err := tx.UpdateUserLink(ctx, database.UpdateUserLinkParams{
OAuthAccessToken: userLink.OAuthAccessToken,
OAuthRefreshToken: userLink.OAuthRefreshToken,
OAuthExpiry: userLink.OAuthExpiry,
UserID: usr.ID,
LoginType: usr.LoginType,
}); err != nil {
return xerrors.Errorf("update user link user_id=%s linked_id=%s: %w", userLink.UserID, userLink.LinkedID, err)
}
}
gitAuthLinks, err := tx.GetGitAuthLinksByUserID(ctx, usr.ID)
if err != nil {
return xerrors.Errorf("get git auth links for user: %w", err)
}
for _, gitAuthLink := range gitAuthLinks {
if !gitAuthLink.OAuthAccessTokenKeyID.Valid && !gitAuthLink.OAuthRefreshTokenKeyID.Valid {
log.Debug(ctx, "skipping git auth link", slog.F("user_id", usr.ID), slog.F("current", idx+1))
continue
}
if _, err := tx.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
ProviderID: gitAuthLink.ProviderID,
UserID: usr.ID,
UpdatedAt: gitAuthLink.UpdatedAt,
OAuthAccessToken: gitAuthLink.OAuthAccessToken,
OAuthRefreshToken: gitAuthLink.OAuthRefreshToken,
OAuthExpiry: gitAuthLink.OAuthExpiry,
}); err != nil {
return xerrors.Errorf("update git auth link user_id=%s provider_id=%s: %w", gitAuthLink.UserID, gitAuthLink.ProviderID, err)
}
}
return nil
}, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
})
if err != nil {
return xerrors.Errorf("update user links: %w", err)
}
log.Debug(ctx, "decrypted user tokens", slog.F("user_id", usr.ID), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
}
// Revoke _all_ keys
for _, c := range ciphers {
if err := db.RevokeDBCryptKey(ctx, c.HexDigest()); err != nil {
return xerrors.Errorf("revoke key: %w", err)
}
log.Info(ctx, "revoked unused key", slog.F("digest", c.HexDigest()))
}
return nil
}
// nolint: gosec
const sqlDeleteEncryptedUserTokens = `
BEGIN;
DELETE FROM user_links
WHERE oauth_access_token_key_id IS NOT NULL
OR oauth_refresh_token_key_id IS NOT NULL;
DELETE FROM git_auth_links
WHERE oauth_access_token_key_id IS NOT NULL
OR oauth_refresh_token_key_id IS NOT NULL;
COMMIT;
`
// Delete deletes all user tokens and revokes all ciphers.
// This is a destructive operation and should only be used
// as a last resort, for example, if the database encryption key has been
// lost.
func Delete(ctx context.Context, log slog.Logger, sqlDB *sql.DB) error {
store := database.New(sqlDB)
_, err := sqlDB.ExecContext(ctx, sqlDeleteEncryptedUserTokens)
if err != nil {
return xerrors.Errorf("delete user links: %w", err)
}
log.Info(ctx, "deleted encrypted user tokens")
log.Info(ctx, "revoking all active keys")
keys, err := store.GetDBCryptKeys(ctx)
if err != nil {
return xerrors.Errorf("get db crypt keys: %w", err)
}
for _, k := range keys {
if !k.ActiveKeyDigest.Valid {
continue
}
if err := store.RevokeDBCryptKey(ctx, k.ActiveKeyDigest.String); err != nil {
return xerrors.Errorf("revoke key: %w", err)
}
log.Info(ctx, "revoked unused key", slog.F("digest", k.ActiveKeyDigest.String))
}
return nil
}

View File

@ -645,25 +645,19 @@ func initCipher(t *testing.T) *aes256 {
func setup(t *testing.T) (db database.Store, cryptDB *dbCrypt, cs []Cipher) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
rawDB, _ := dbtestutil.NewDB(t)
cs = append(cs, initCipher(t))
cdb, err := New(ctx, rawDB, cs...)
cdb, err := New(context.Background(), rawDB, cs...)
require.NoError(t, err)
cryptDB, ok := cdb.(*dbCrypt)
require.True(t, ok)
return rawDB, cryptDB, cs
}
func setupNoCiphers(t *testing.T) (db database.Store, cryptodb *dbCrypt) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
rawDB, _ := dbtestutil.NewDB(t)
cdb, err := New(ctx, rawDB)
cdb, err := New(context.Background(), rawDB)
require.NoError(t, err)
cryptDB, ok := cdb.(*dbCrypt)
require.True(t, ok)

View File

@ -379,6 +379,8 @@ export interface DeploymentValues {
readonly agent_fallback_troubleshooting_url?: string;
readonly browser_only?: boolean;
readonly scim_api_key?: string;
// This is likely an enum in an external package ("github.com/coder/coder/v2/cli/clibase.StringArray")
readonly external_token_encryption_keys?: string[];
readonly provisioner?: ProvisionerConfig;
readonly rate_limit?: RateLimitConfig;
// This is likely an enum in an external package ("github.com/coder/coder/v2/cli/clibase.StringArray")
@ -1639,6 +1641,7 @@ export type FeatureName =
| "audit_log"
| "browser_only"
| "external_provisioner_daemons"
| "external_token_encryption"
| "high_availability"
| "multiple_git_auth"
| "scim"
@ -1654,6 +1657,7 @@ export const FeatureNames: FeatureName[] = [
"audit_log",
"browser_only",
"external_provisioner_daemons",
"external_token_encryption",
"high_availability",
"multiple_git_auth",
"scim",