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:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 15:43:41 -08:00
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,
+130
View File
@@ -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)]);
}
}
+8
View File
@@ -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,
};
}
+16
View File
@@ -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)"}.`,
+15
View File
@@ -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