feat: Workspace encryption key can be manually updated (#3223)

* feat: Workspace encryption key can be manually updated

* sqlx prepare
This commit is contained in:
Guillaume Bouvignies
2024-02-15 17:02:45 +01:00
committed by GitHub
parent 28192ec014
commit e8ed4783b2
19 changed files with 396 additions and 28 deletions
@@ -48,7 +48,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -69,7 +69,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -28,7 +28,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -42,7 +42,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT key FROM workspace_key WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "3a637063e1d256639b0e900606b641ab71cc1b5049a104e0699e81484a61ae63"
}
@@ -62,7 +62,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -37,7 +37,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_key SET key = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "7ee6056b7fb40b312333b489788337574398d63a6ab85171e43c3ab76400ec22"
}
@@ -42,7 +42,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "value",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_secret",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd"
}
@@ -42,7 +42,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -42,7 +42,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
+74 -1
View File
@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
version: 1.268.0
version: 1.269.0
title: Windmill API
contact:
name: Windmill Team
@@ -1961,6 +1961,58 @@ paths:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/encryption_key:
get:
summary: retrieves the encryption key for this workspace
operationId: getWorkspaceEncryptionKey
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_0
responses:
'200':
description: status
content:
application/json:
schema:
type: object
properties:
key:
type: string
required:
- key
post:
summary: update the encryption key for this workspace
operationId: setWorkspaceEncryptionKey
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_0
requestBody:
description: New encryption key
required: true
content:
application/json:
schema:
type: object
properties:
new_key:
type: string
required:
- new_key
responses:
'200':
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/default_app:
get:
summary: get default app for workspace
@@ -7109,6 +7161,13 @@ paths:
in: query
schema: &ref_64
type: string
- name: all_workspaces
description: >-
get jobs from all workspaces (only valid if request come from the
`admins` workspace)
in: query
schema:
type: boolean
responses:
'200':
description: All queued jobs
@@ -7334,6 +7393,13 @@ paths:
in: path
required: true
schema: *ref_0
- name: all_workspaces
description: >-
get jobs from all workspaces (only valid if request come from the
`admins` workspace)
in: query
schema:
type: boolean
responses:
'200':
description: queue count
@@ -7706,6 +7772,13 @@ paths:
in: query
schema:
type: boolean
- name: all_workspaces
description: >-
get jobs from all workspaces (only valid if request come from the
`admins` workspace)
in: query
schema:
type: boolean
responses:
'200':
description: All jobs
+48
View File
@@ -1574,6 +1574,54 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/encryption_key:
get:
summary: retrieves the encryption key for this workspace
operationId: getWorkspaceEncryptionKey
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
application/json:
schema:
type: object
properties:
key:
type: string
required:
- key
post:
summary: update the encryption key for this workspace
operationId: setWorkspaceEncryptionKey
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: New encryption key
required: true
content:
application/json:
schema:
type: object
properties:
new_key:
type: string
required:
- new_key
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/default_app:
get:
summary: get default app for workspace
+2 -4
View File
@@ -12,7 +12,6 @@ use axum::{
routing::post,
Router,
};
use magic_crypt::MagicCryptTrait;
use quick_cache::sync::Cache;
use serde_json::value::RawValue;
use windmill_audit::audit_ee::audit_log;
@@ -22,6 +21,7 @@ use windmill_common::{
variables::build_crypt,
};
use crate::variables::decrypt;
use serde::Deserialize;
pub fn workspaced_service() -> Router {
@@ -73,9 +73,7 @@ async fn get_variable_or_self(path: String, db: &DB, w_id: &String) -> Result<St
.await?;
if variable.is_secret {
let mc = build_crypt(&mut tx, &w_id).await?;
variable.value = mc
.decrypt_base64_to_string(variable.value)
.map_err(|e| Error::InternalErr(e.to_string()))?;
variable.value = decrypt(&mc, variable.value)?;
}
tx.commit().await?;
Ok(variable.value)
+7 -7
View File
@@ -166,10 +166,7 @@ async fn get_variable(
let mc = build_crypt(&mut tx, &w_id).await?;
tx.commit().await?;
Some(
mc.decrypt_base64_to_string(value)
.map_err(|e| Error::InternalErr(e.to_string()))?,
)
Some(decrypt(&mc, value)?)
} else if q.include_encrypted.unwrap_or(false) {
Some(value)
} else {
@@ -637,9 +634,7 @@ pub async fn get_value_internal<'c>(
} else if !value.is_empty() {
let mc = build_crypt(&mut tx, &w_id).await?;
tx.commit().await?;
mc.decrypt_base64_to_string(value)
.map_err(|e| Error::InternalErr(e.to_string()))?
decrypt(&mc, value)?
} else {
"".to_string()
}
@@ -653,3 +648,8 @@ pub async fn get_value_internal<'c>(
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
mc.encrypt_str_to_base64(value)
}
pub fn decrypt(mc: &MagicCrypt256, value: String) -> Result<String> {
mc.decrypt_base64_to_string(value)
.map_err(|e| Error::InternalErr(e.to_string()))
}
+100 -5
View File
@@ -33,7 +33,6 @@ use axum::{
use chrono::Utc;
#[cfg(feature = "stripe")]
use chrono::{Datelike, TimeZone, Timelike};
use magic_crypt::MagicCryptTrait;
use regex::Regex;
#[cfg(feature = "stripe")]
use stripe::CustomerId;
@@ -57,6 +56,7 @@ use windmill_common::{
};
use windmill_queue::QueueTransaction;
use crate::variables::{decrypt, encrypt};
use hyper::{header, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map};
@@ -64,6 +64,11 @@ use sqlx::{FromRow, Postgres, Transaction};
use tempfile::TempDir;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
use windmill_common::utils::not_found_if_none;
lazy_static::lazy_static! {
static ref WORKSPACE_KEY_REGEXP: Regex = Regex::new("^[a-zA-Z0-9]{64}$").unwrap();
}
pub fn workspaced_service() -> Router {
let router = Router::new()
@@ -95,6 +100,10 @@ pub fn workspaced_service() -> Router {
.route("/edit_git_sync_config", post(edit_git_sync_config))
.route("/edit_default_app", post(edit_default_app))
.route("/default_app", get(get_default_app))
.route(
"/encryption_key",
get(get_encryption_key).post(set_encryption_key),
)
.route("/leave", post(leave_workspace));
#[cfg(feature = "stripe")]
@@ -1334,6 +1343,95 @@ async fn edit_error_handler(
Ok(format!("Edit error_handler for workspace {}", &w_id))
}
#[derive(Serialize)]
pub struct GetEncryptionKeyResponse {
key: String,
}
async fn get_encryption_key(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<GetEncryptionKeyResponse> {
require_super_admin(&db, &authed.email).await?;
let encryption_key_opt = sqlx::query_scalar!(
"SELECT key FROM workspace_key WHERE workspace_id = $1",
w_id
)
.fetch_optional(&db)
.await?;
let encryption_key = not_found_if_none(encryption_key_opt, "workspace_encryption_key", w_id)?;
return Ok(Json(GetEncryptionKeyResponse { key: encryption_key }));
}
#[derive(Deserialize)]
struct SetEncryptionKeyRequest {
new_key: String,
}
async fn set_encryption_key(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(request): Json<SetEncryptionKeyRequest>,
) -> Result<()> {
require_super_admin(&db, &authed.email).await?;
if !WORKSPACE_KEY_REGEXP.is_match(request.new_key.as_str()) {
return Err(Error::BadRequest(
"Encryption key should be an alphanumeric string of 64 characters".to_string(),
));
}
let mut tx = db.begin().await?;
let previous_encryption_key = build_crypt(&mut tx, w_id.as_str()).await?;
sqlx::query!(
"UPDATE workspace_key SET key = $1 WHERE workspace_id = $2",
request.new_key.clone(),
w_id
)
.execute(&mut *tx)
.await?;
let new_encryption_key = build_crypt(&mut tx, w_id.as_str()).await?;
let mut truncated_new_key = request.new_key.clone();
truncated_new_key.truncate(8);
tracing::warn!(
"Re-encrypting all secrets for workspace {}. New key is {}***",
w_id,
truncated_new_key
);
let all_variables = sqlx::query!(
"SELECT path, value, is_secret FROM variable WHERE workspace_id = $1",
w_id
)
.fetch_all(&mut *tx)
.await?;
for variable in all_variables {
if !variable.is_secret {
continue;
}
let decrypted_value = decrypt(&previous_encryption_key, variable.value)?;
let new_encrypted_value = encrypt(&new_encryption_key, decrypted_value.as_str());
sqlx::query!(
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
new_encrypted_value,
w_id,
variable.path
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
return Ok(());
}
async fn list_workspaces_as_super_admin(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -2321,10 +2419,7 @@ async fn tarball_workspace(
&& var.value.is_some()
&& var.is_secret
{
var.value = Some(
mc.decrypt_base64_to_string(var.value.unwrap())
.map_err(|e| Error::InternalErr(e.to_string()))?,
);
var.value = Some(decrypt(&mc, var.value.unwrap())?);
}
let var_str = &to_string_without_metadata(&var, false).unwrap();
archive
@@ -33,7 +33,17 @@
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { setQueryWithoutLoad, emptyString, tryEvery } from '$lib/utils'
import { Scroll, Slack, XCircle, RotateCw, CheckCircle2, X, Plus, Loader2 } from 'lucide-svelte'
import {
Scroll,
Slack,
XCircle,
RotateCw,
CheckCircle2,
X,
Plus,
Loader2,
Save
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
@@ -101,6 +111,10 @@
status: 'running' | 'success' | 'failure' | undefined
}[]
let workspaceDefaultAppPath: string | undefined = undefined
let workspaceEncryptionKey: string | undefined = undefined
let editedWorkspaceEncryptionKey: string | undefined = undefined
let workspaceReencryptionInProgress: boolean = false
let encryptionKeyRegex = /^[a-zA-Z0-9]{64}$/
let codeCompletionEnabled: boolean = false
let tab =
($page.url.searchParams.get('tab') as
@@ -338,6 +352,37 @@
}
}
async function loadWorkspaceEncryptionKey(): Promise<void> {
let resp = await WorkspaceService.getWorkspaceEncryptionKey({
workspace: $workspaceStore!
})
workspaceEncryptionKey = resp.key
editedWorkspaceEncryptionKey = resp.key
}
async function setWorkspaceEncryptionKey(): Promise<void> {
if (
emptyString(editedWorkspaceEncryptionKey) ||
workspaceEncryptionKey === editedWorkspaceEncryptionKey
) {
return
}
const timeStart = new Date().getTime()
workspaceReencryptionInProgress = true
await WorkspaceService.setWorkspaceEncryptionKey({
workspace: $workspaceStore!,
requestBody: {
new_key: editedWorkspaceEncryptionKey ?? '' // cannot be undefined at this point
}
})
await loadWorkspaceEncryptionKey()
const timeEnd = new Date().getTime()
sendUserToast('All workspace secrets have been re-encrypted with the new key')
setTimeout(() => {
workspaceReencryptionInProgress = false
}, 1000 - (timeEnd - timeStart))
}
async function loadSettings(): Promise<void> {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
team_name = settings.slack_name
@@ -601,6 +646,9 @@
<Tab size="xs" value="default_app">
<div class="flex gap-2 items-center my-1"> Default App </div>
</Tab>
<Tab size="xs" value="encryption">
<div class="flex gap-2 items-center my-1"> Encryption </div>
</Tab>
<Tab size="xs" value="export_delete">
<div class="flex gap-2 items-center my-1"> Delete Workspace </div>
</Tab>
@@ -1367,6 +1415,50 @@ git push</code
/>
{/key}
</div>
{:else if tab == 'encryption'}
<PageHeader title="Workspace secret encryption" primary={false} />
<Alert type="info" title="Windmill EE only feature">
When updating the encryption key of a workspace, all secrets will be re-encrypted with the
new key and the previous key will be replaced by the new one.
<br />
If you're manually updating the key to match another workspace key from another Windmill instance,
make sure not to use the 'SECRET_SALT' environment variable or, if you're using it, make sure
it the salt matches across both instances.
</Alert>
<div class="mt-5 flex gap-1 mb-10">
<Button
color="blue"
disabled={editedWorkspaceEncryptionKey === workspaceEncryptionKey ||
!encryptionKeyRegex.test(editedWorkspaceEncryptionKey ?? '')}
startIcon={{
icon: workspaceReencryptionInProgress ? RotateCw : Save,
classes: workspaceReencryptionInProgress ? 'animate-spin' : ''
}}
on:click={() => {
setWorkspaceEncryptionKey()
}}>Save & Re-encrypt workspace</Button
>
</div>
<h6> Workspace encryption key </h6>
<div class="flex gap-2 mt-1">
<input
class="justify-start"
type="text"
placeholder={'*'.repeat(64)}
bind:value={editedWorkspaceEncryptionKey}
/>
<Button
color="light"
on:click={() => {
loadWorkspaceEncryptionKey()
}}>Load current key</Button
>
</div>
{#if !emptyString(editedWorkspaceEncryptionKey) && !encryptionKeyRegex.test(editedWorkspaceEncryptionKey ?? '')}
<div class="text-xs text-red-600">
Key invalid - it should be 64 characters long and only contain letters and numbers.
</div>
{/if}
{/if}
{:else}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4" role="alert">