mirror of https://github.com/coder/coder.git
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:
parent
ed7f682fd1
commit
7d7c84bb4d
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -7112,6 +7112,12 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"external_token_encryption_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"git_auth": {
|
||||
"$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig"
|
||||
},
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
@ -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 | | |
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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. |
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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 |
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
Usage: coder server dbcrypt
|
||||
|
||||
Manage database encryption.
|
||||
|
||||
[1mSubcommands[0m
|
||||
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.
|
|
@ -0,0 +1,17 @@
|
|||
Usage: coder server dbcrypt decrypt [flags]
|
||||
|
||||
Decrypt a previously encrypted database.
|
||||
|
||||
[1mOptions[0m
|
||||
--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.
|
|
@ -0,0 +1,15 @@
|
|||
Usage: coder server dbcrypt delete [flags]
|
||||
|
||||
Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.
|
||||
|
||||
Aliases: rm
|
||||
|
||||
[1mOptions[0m
|
||||
--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.
|
|
@ -0,0 +1,20 @@
|
|||
Usage: coder server dbcrypt rotate [flags]
|
||||
|
||||
Rotate database encryption keys.
|
||||
|
||||
[1mOptions[0m
|
||||
--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.
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue