feat(aws): disclose imported AWS connections and keys in the import preview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e42261bac8
commit
664697b151
7 files changed
+210
-1
No files matched your search
@@ -363,7 +363,8 @@ pub async fn preview_settings_import(
|
||||
|
||||
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let local_hosts = state.settings_store.get().hosts;
|
||||
let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_exists);
|
||||
|
||||
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||
path,
|
||||
|
||||
@@ -177,6 +177,30 @@ pub struct SettingsImportPreview {
|
||||
/// Non-blank marketplace account tokens the import restores.
|
||||
#[serde(default)]
|
||||
pub marketplace_account_token_count: usize,
|
||||
/// The AWS connections the import brings. Applied wholesale, so a crafted
|
||||
/// export could otherwise add or replace one unannounced. Names and
|
||||
/// profile names are cleaned; no key values, only the count below.
|
||||
#[serde(default)]
|
||||
pub aws_connections: Vec<AwsConnectionPreview>,
|
||||
/// Non-blank AWS access-key entries the import restores to the keychain.
|
||||
#[serde(default)]
|
||||
pub aws_connection_key_count: usize,
|
||||
}
|
||||
|
||||
/// One imported AWS connection as the confirmation dialog shows it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AwsConnectionPreview {
|
||||
pub name: String,
|
||||
/// `"profile"` or `"keys"`.
|
||||
pub auth: String,
|
||||
/// The profile name, for a `profile` connection.
|
||||
pub profile_name: Option<String>,
|
||||
pub default_region: String,
|
||||
/// A connection with this id exists here with different content.
|
||||
pub overwrites_existing: bool,
|
||||
/// The import carries keys for this id and a keychain entry for it
|
||||
/// already exists here (its value is never read into the preview).
|
||||
pub overwrites_keys: bool,
|
||||
}
|
||||
|
||||
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
||||
@@ -202,6 +226,45 @@ fn sanitize_for_preview(value: &str) -> String {
|
||||
|
||||
impl SettingsImportPreview {
|
||||
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
||||
Self::from_payload_against(payload, &[], |_| false)
|
||||
}
|
||||
|
||||
/// As [`Self::from_payload`], also flagging what the import would
|
||||
/// overwrite: `local_hosts` are the connections held now and
|
||||
/// `local_has_keys` says whether a keychain entry exists for an id.
|
||||
pub fn from_payload_against(
|
||||
payload: &SettingsExportPayload,
|
||||
local_hosts: &[crate::models::host_connection::HostConnection],
|
||||
local_has_keys: impl Fn(&str) -> bool,
|
||||
) -> Self {
|
||||
use crate::models::host_connection::{AwsAuth, HostKind};
|
||||
let carried_keys = |id: &str| {
|
||||
payload
|
||||
.secrets
|
||||
.aws_connection_keys
|
||||
.get(id)
|
||||
.is_some_and(|v| !v.trim().is_empty())
|
||||
};
|
||||
let aws_connections = payload
|
||||
.settings
|
||||
.hosts
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let HostKind::Aws { auth, default_region } = &h.kind;
|
||||
let (auth_kind, profile_name) = match auth {
|
||||
AwsAuth::Profile { name } => ("profile", Some(sanitize_for_preview(name))),
|
||||
AwsAuth::Keys { .. } => ("keys", None),
|
||||
};
|
||||
AwsConnectionPreview {
|
||||
name: sanitize_for_preview(&h.name),
|
||||
auth: auth_kind.to_string(),
|
||||
profile_name,
|
||||
default_region: sanitize_for_preview(default_region),
|
||||
overwrites_existing: local_hosts.iter().any(|l| l.id == h.id && l != h),
|
||||
overwrites_keys: carried_keys(&h.id) && local_has_keys(&h.id),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||
let sanitized_non_blank = |s: &Option<String>| {
|
||||
s.as_deref()
|
||||
@@ -246,6 +309,13 @@ impl SettingsImportPreview {
|
||||
.values()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.count(),
|
||||
aws_connections,
|
||||
aws_connection_key_count: payload
|
||||
.secrets
|
||||
.aws_connection_keys
|
||||
.values()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,4 +553,64 @@ mod tests {
|
||||
assert_eq!(preview.global_plugin_install_count, 2);
|
||||
assert_eq!(preview.global_hook_install_count, 1);
|
||||
}
|
||||
|
||||
fn aws_host(id: &str, name: &str, keys: bool) -> crate::models::host_connection::HostConnection {
|
||||
use crate::models::host_connection::*;
|
||||
HostConnection {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: if keys {
|
||||
AwsAuth::Keys { keyring_ref: id.into() }
|
||||
} else {
|
||||
AwsAuth::Profile { name: "prod-profile".into() }
|
||||
},
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_preview_discloses_aws_connections_and_key_count() {
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
aws_connection_keys: BTreeMap::from([
|
||||
("k1".to_string(), "{\"secret_access_key\":\"test-secret-not-real\"}".to_string()),
|
||||
("k2".to_string(), " ".to_string()),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.hosts = vec![aws_host("k1", "Ke\nys", true), aws_host("p1", "Prof", false)];
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.aws_connection_key_count, 1);
|
||||
assert_eq!(preview.aws_connections.len(), 2);
|
||||
assert_eq!(preview.aws_connections[0].name, "Keys");
|
||||
assert_eq!(preview.aws_connections[0].auth, "keys");
|
||||
assert_eq!(preview.aws_connections[1].auth, "profile");
|
||||
assert_eq!(preview.aws_connections[1].profile_name.as_deref(), Some("prod-profile"));
|
||||
assert_eq!(preview.aws_connections[1].default_region, "us-west-2");
|
||||
assert!(!serde_json::to_string(&preview).unwrap().contains("test-secret-not-real"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_preview_flags_aws_overwrites() {
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
aws_connection_keys: BTreeMap::from([("k1".to_string(), "{}".to_string())]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.hosts = vec![
|
||||
aws_host("k1", "Keys", true),
|
||||
aws_host("same", "Same", false),
|
||||
aws_host("changed", "New name", false),
|
||||
aws_host("fresh", "Fresh", false),
|
||||
];
|
||||
let local = vec![aws_host("same", "Same", false), aws_host("changed", "Old name", false)];
|
||||
let preview =
|
||||
SettingsImportPreview::from_payload_against(&payload, &local, |id| id == "k1");
|
||||
let flags: Vec<_> = preview
|
||||
.aws_connections
|
||||
.iter()
|
||||
.map(|c| (c.overwrites_existing, c.overwrites_keys))
|
||||
.collect();
|
||||
assert_eq!(flags, vec![(false, true), (false, false), (true, false), (false, false)]);
|
||||
}
|
||||
}
|
||||
@@ -474,6 +474,14 @@ pub fn get_aws_keys(connection_id: &str) -> Result<Option<AwsKeys>, String> {
|
||||
.map_err(|_| "The stored AWS access keys are unreadable".to_string())
|
||||
}
|
||||
|
||||
/// Whether any entry exists for the connection, readable or not. Never
|
||||
/// returns or logs a value.
|
||||
pub fn aws_keys_entry_exists(connection_id: &str) -> bool {
|
||||
aws_connection_service(connection_id)
|
||||
.and_then(|svc| read_entry(&svc, "the AWS access keys"))
|
||||
.is_ok_and(|v| v.is_some())
|
||||
}
|
||||
|
||||
/// Missing entry is success. Unused until connection removal lands.
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> {
|
||||
|
||||
@@ -36,6 +36,8 @@ const samplePreview: SettingsImportPreview = {
|
||||
global_hook_install_count: 0,
|
||||
global_plugin_install_count: 0,
|
||||
marketplace_account_token_count: 0,
|
||||
aws_connections: [],
|
||||
aws_connection_key_count: 0,
|
||||
};
|
||||
|
||||
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
|
||||
@@ -80,6 +82,41 @@ describe("ImportSettingsModal", () => {
|
||||
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the AWS connections and key count, and flags overwrites", async () => {
|
||||
previewSettingsImport.mockResolvedValue({
|
||||
...samplePreview,
|
||||
aws_connections: [
|
||||
{
|
||||
name: "Prod",
|
||||
auth: "keys",
|
||||
profile_name: null,
|
||||
default_region: "us-west-2",
|
||||
overwrites_existing: true,
|
||||
overwrites_keys: true,
|
||||
},
|
||||
{
|
||||
name: "Dev",
|
||||
auth: "profile",
|
||||
profile_name: "dev-profile",
|
||||
default_region: "eu-west-1",
|
||||
overwrites_existing: false,
|
||||
overwrites_keys: false,
|
||||
},
|
||||
],
|
||||
aws_connection_key_count: 1,
|
||||
});
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(await screen.findByText(/AWS connection "Prod" \(access keys, us-west-2\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AWS connection "Dev" \(profile dev-profile, eu-west-1\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 AWS access key set$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Overwrites the AWS access keys stored for "Prod"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Replaces your existing AWS connection.*"Prod"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns about a custom Docker image every time, not just on change", async () => {
|
||||
previewSettingsImport.mockResolvedValue({
|
||||
...samplePreview,
|
||||
|
||||
@@ -24,6 +24,8 @@ function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImport
|
||||
global_hook_install_count: 0,
|
||||
global_plugin_install_count: 0,
|
||||
marketplace_account_token_count: 0,
|
||||
aws_connections: [],
|
||||
aws_connection_key_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ export function describeImport(preview: SettingsImportPreview): string[] {
|
||||
const n = preview.marketplace_account_token_count;
|
||||
items.push(`${n} marketplace account token${n === 1 ? "" : "s"}`);
|
||||
}
|
||||
for (const c of preview.aws_connections) {
|
||||
const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim();
|
||||
items.push(`AWS connection "${c.name}" (${auth}, ${c.default_region})`);
|
||||
}
|
||||
if (preview.aws_connection_key_count > 0) {
|
||||
const n = preview.aws_connection_key_count;
|
||||
items.push(`${n} AWS access key set${n === 1 ? "" : "s"}`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -82,6 +90,14 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
|
||||
`Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.`,
|
||||
);
|
||||
}
|
||||
for (const c of preview.aws_connections) {
|
||||
if (c.overwrites_existing) {
|
||||
warnings.push(`Replaces your existing AWS connection with different settings: "${c.name}".`);
|
||||
}
|
||||
if (c.overwrites_keys) {
|
||||
warnings.push(`Overwrites the AWS access keys stored for "${c.name}".`);
|
||||
}
|
||||
}
|
||||
if (preview.image_source === "custom") {
|
||||
warnings.push(
|
||||
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
|
||||
|
||||
@@ -425,6 +425,21 @@ export interface SettingsImportPreview {
|
||||
global_plugin_install_count: number;
|
||||
/** Marketplace account tokens the import restores to the keychain. */
|
||||
marketplace_account_token_count: number;
|
||||
/** AWS connections the import brings (names cleaned; no key values). */
|
||||
aws_connections: AwsConnectionPreview[];
|
||||
/** AWS access-key entries the import restores to the keychain. */
|
||||
aws_connection_key_count: number;
|
||||
}
|
||||
|
||||
export interface AwsConnectionPreview {
|
||||
name: string;
|
||||
auth: "profile" | "keys";
|
||||
profile_name: string | null;
|
||||
default_region: string;
|
||||
/** A connection with this id exists here with different content. */
|
||||
overwrites_existing: boolean;
|
||||
/** The import carries keys for an id that already has keys stored here. */
|
||||
overwrites_keys: boolean;
|
||||
}
|
||||
|
||||
/** What `apply_settings_import` returns: the settings that were actually
|
||||
|
||||
Reference in new issue
Block a user