Repository: kpcyrd/sn0int (language: rust)

Issue:
Add breaches
There was a discussion on irc whether it would be useful to have a breach struct and allow linking emails to a breach. I think this would be useful, for performance reasons we wouldn't store the full breach in our database but instead we would probably go with something like this:

```
# breaches
- id: i32,
- value (name of the breach): String

# breach_emails
email_id: i32,
breach_id: i32,
password: Option<String>
```

This would allow selecting all scoped emails that are involved in a breach and also list all breaches an email has been involved with. The password is optional depending on whether we have a copy of the breach or not.

Note that due to breach compilations a breach can contain more than one password for an email.


Discussion / hints:

Fix patch:
diff --git a/migrations/2019-02-11-011316_breaches/down.sql b/migrations/2019-02-11-011316_breaches/down.sql
new file mode 100644
index 00000000..75e0038f
--- /dev/null
+++ b/migrations/2019-02-11-011316_breaches/down.sql
@@ -0,0 +1,2 @@
+DROP TABLE breach_emails;
+DROP TABLE breaches;
diff --git a/migrations/2019-02-11-011316_breaches/up.sql b/migrations/2019-02-11-011316_breaches/up.sql
new file mode 100644
index 00000000..c7e44df6
--- /dev/null
+++ b/migrations/2019-02-11-011316_breaches/up.sql
@@ -0,0 +1,16 @@
+CREATE TABLE breaches (
+    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+    value VARCHAR NOT NULL,
+    unscoped BOOLEAN DEFAULT 0 NOT NULL,
+    CONSTRAINT breach_unique UNIQUE (value)
+);
+
+CREATE TABLE breach_emails (
+    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+    breach_id INTEGER NOT NULL,
+    email_id INTEGER NOT NULL,
+    password VARCHAR,
+    FOREIGN KEY(breach_id) REFERENCES breaches(id) ON DELETE CASCADE,
+    FOREIGN KEY(email_id) REFERENCES emails(id) ON DELETE CASCADE,
+    CONSTRAINT breach_emails_unique UNIQUE (breach_id, email_id, password)
+);
diff --git a/src/cmd/add_cmd.rs b/src/cmd/add_cmd.rs
index 314911b7..71269933 100644
--- a/src/cmd/add_cmd.rs
+++ b/src/cmd/add_cmd.rs
@@ -25,6 +25,8 @@ pub enum Args {
     Network(AddNetwork),
     #[structopt(name="account")]
     Account(AddAccount),
+    #[structopt(name="breach")]
+    Breach(AddBreach),
 }
 
 #[derive(Debug, StructOpt)]
@@ -67,6 +69,11 @@ pub struct AddAccount {
     username: Option<String>,
 }
 
+#[derive(Debug, StructOpt)]
+pub struct AddBreach {
+    name: Option<String>,
+}
+
 pub fn run(rl: &mut Readline, args: &[String]) -> Result<()> {
     let args = Args::from_iter_safe(args)?;
     match args {
@@ -77,6 +84,7 @@ pub fn run(rl: &mut Readline, args: &[String]) -> Result<()> {
         Args::Device(args) => add_device(rl, args),
         Args::Network(args) => add_network(rl, args),
         Args::Account(args) => add_account(rl, args),
+        Args::Breach(args) => add_breach(rl, args),
     }
 }
 
@@ -245,3 +253,19 @@ fn add_account(rl: &mut Readline, args: AddAccount) -> Result<()> {
 
     Ok(())
 }
+
+fn add_breach(rl: &mut Readline, args: AddBreach) -> Result<()> {
+    let name = match args.name {
+        Some(name) => name,
+        _ => {
+            let name = utils::question("Name")?;
+            name
+        }
+    };
+
+    rl.db().insert_struct(NewBreach {
+        value: &name,
+    })?;
+
+    Ok(())
+}
diff --git a/src/cmd/select_cmd.rs b/src/cmd/select_cmd.rs
index 5ea7f57d..6eeafedf 100644
--- a/src/cmd/select_cmd.rs
+++ b/src/cmd/select_cmd.rs
@@ -51,6 +51,9 @@ pub enum Target {
     #[structopt(name="accounts")]
     /// Select accounts
     Accounts(Filter),
+    #[structopt(name="breaches")]
+    /// Select breaches
+    Breaches(Filter),
 }
 
 #[derive(Debug, StructOpt)]
@@ -105,6 +108,7 @@ impl Cmd for Args {
             Target::Devices(filter) => printer.select::<Device>(&filter),
             Target::Networks(filter) => printer.select::<Network>(&filter),
             Target::Accounts(filter) => printer.select::<Account>(&filter),
+            Target::Breaches(filter) => printer.select::<Breach>(&filter),
         }
     }
 }
diff --git a/src/complete.rs b/src/complete.rs
index 48ed70cd..31b3d345 100644
--- a/src/complete.rs
+++ b/src/complete.rs
@@ -34,7 +34,8 @@ impl CmdCompleter {
                             "phonenumbers",
                             "devices",
                             "networks",
-                            "accounts"];
+                            "accounts",
+                            "breaches"];
 
             let results: Vec<String> = options.iter()
                 .filter(|x| x.starts_with(arg))
@@ -95,7 +96,8 @@ impl Completer for CmdCompleter {
                                         "phonenumber",
                                         "device",
                                         "network",
-                                        "account"];
+                                        "account",
+                                        "breach"];
 
                         let results: Vec<String> = options.iter()
                             .filter(|x| x.starts_with(arg))
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 92e25eaf..880f0fc4 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -45,6 +45,8 @@ pub enum Family {
     Network,
     NetworkDevice,
     Account,
+    Breach,
+    BreachEmail,
 }
 
 impl FromStr for Family {
@@ -63,6 +65,8 @@ impl FromStr for Family {
             "network" => Family::Network,
             "network-device" => Family::NetworkDevice,
             "account" => Family::Account,
+            "breach" => Family::Breach,
+            "breach-email" => Family::BreachEmail,
             _ => bail!("Unknown object family"),
         })
     }
@@ -194,6 +198,14 @@ impl Database {
                 url: object.url.as_ref(),
                 last_seen: object.last_seen,
             }),
+            Insert::Breach(object) => self.insert_struct(NewBreach {
+                value: &object.value,
+            }),
+            Insert::BreachEmail(object) => self.insert_breach_email_struct(NewBreachEmail {
+                breach_id: object.breach_id,
+                email_id: object.email_id,
+                password: object.password.as_ref(),
+            }),
         }
     }
 
@@ -232,8 +244,8 @@ impl Database {
     }
 
     pub fn insert_network_device_struct(&self, network_device: &NewNetworkDevice) -> Result<Option<(DbChange, i32)>> {
-        if let Some(subdomain_ipaddr_id) = NetworkDevice::get_id_opt(self, &(network_device.network_id, network_device.device_id))? {
-            Ok(Some((DbChange::None, subdomain_ipaddr_id)))
+        if let Some(network_device_id) = NetworkDevice::get_id_opt(self, &(network_device.network_id, network_device.device_id))? {
+            Ok(Some((DbChange::None, network_device_id)))
         } else {
             diesel::insert_into(network_devices::table)
                 .values(network_device)
@@ -243,6 +255,28 @@ impl Database {
         }
     }
 
+    pub fn insert_breach_email_struct(&self, obj: NewBreachEmail) -> Result<Option<(DbChange, i32)>> {
+        let password = obj.password.map(|x| x.clone());
+        if let Some(existing) = BreachEmail::get_opt(self, &(obj.breach_id, obj.email_id, password.clone()))? {
+            let id = <BreachEmail as Model>::id(&existing);
+
+            let update = obj.upsert(&existing);
+            if update.is_dirty() {
+                update.apply(&self)?;
+                Ok(Some((DbChange::Update(update.generic()), id)))
+            } else {
+                Ok(Some((DbChange::None, id)))
+            }
+        } else {
+            let value = &(obj.breach_id, obj.email_id, password);
+            diesel::insert_into(breach_emails::table)
+                .values(obj)
+                .execute(&self.db)?;
+            let id = BreachEmail::get_id(self, value)?;
+            Ok(Some((DbChange::Insert, id)))
+        }
+    }
+
     //
 
     pub fn update_generic(&self, object: &Update) -> Result<i32> {
@@ -256,6 +290,7 @@ impl Database {
             Update::Network(object) => self.update_network(object),
             Update::NetworkDevice(object) => self.update_network_device(object),
             Update::Account(object) => self.update_account(object),
+            Update::BreachEmail(object) => self.update_breach_email(object),
         }
     }
 
@@ -331,6 +366,14 @@ impl Database {
         Ok(account.id)
     }
 
+    pub fn update_breach_email(&self, breach_email: &BreachEmailUpdate) -> Result<i32> {
+        use crate::schema::breach_emails::columns::*;
+        diesel::update(breach_emails::table.filter(id.eq(breach_email.id)))
+            .set(breach_email)
+            .execute(&self.db)?;
+        Ok(breach_email.id)
+    }
+
     fn get_opt_typed<T: Model + Scopable>(&self, value: &T::ID) -> Result<Option<i32>> {
         match T::get_opt(self, &value)? {
             Some(ref obj) if obj.scoped() => Ok(Some(obj.id())),
@@ -351,6 +394,8 @@ impl Database {
             Family::Network => self.get_opt_typed::<Network>(&value),
             Family::NetworkDevice => bail!("Unsupported operation"),
             Family::Account => self.get_opt_typed::<Account>(&value),
+            Family::Breach => self.get_opt_typed::<Breach>(&value),
+            Family::BreachEmail => bail!("Unsupported operation"),
         }
     }
 
diff --git a/src/models/breach.rs b/src/models/breach.rs
new file mode 100644
index 00000000..b02965a0
--- /dev/null
+++ b/src/models/breach.rs
@@ -0,0 +1,273 @@
+use crate::errors::*;
+//use crate::fmt::Write;
+use crate::fmt::colors::*;
+use diesel;
+use diesel::prelude::*;
+use crate::models::*;
+
+
+#[derive(Identifiable, Queryable, Serialize, Deserialize, PartialEq, Debug)]
+#[table_name="breaches"]
+pub struct Breach {
+    pub id: i32,
+    pub value: String,
+    pub unscoped: bool,
+}
+
+impl Model for Breach {
+    type ID = str;
+
+    fn to_string(&self) -> String {
+        self.value.to_owned()
+    }
+
+    fn list(db: &Database) -> Result<Vec<Self>> {
+        use crate::schema::breaches::dsl::*;
+
+        let results = breaches.load::<Self>(db.db())?;
+
+        Ok(results)
+    }
+
+    fn filter(db: &Database, filter: &Filter) -> Result<Vec<Self>> {
+        use crate::schema::breaches::dsl::*;
+
+        let query = breaches.filter(filter.sql());
+        let results = query.load::<Self>(db.db())?;
+
+        Ok(results)
+    }
+
+    fn delete(db: &Database, filter: &Filter) -> Result<usize> {
+        use crate::schema::breaches::dsl::*;
+
+        diesel::delete(breaches.filter(filter.sql()))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+
+    fn delete_id(db: &Database, my_id: i32) -> Result<usize> {
+        use crate::schema::breaches::dsl::*;
+
+        diesel::delete(breaches.filter(id.eq(my_id)))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+
+    fn id(&self) -> i32 {
+        self.id
+    }
+
+    fn value(&self) -> &Self::ID {
+        &self.value
+    }
+
+    fn by_id(db: &Database, my_id: i32) -> Result<Self> {
+        use crate::schema::breaches::dsl::*;
+
+        let domain = breaches.filter(id.eq(my_id))
+            .first::<Self>(db.db())?;
+
+        Ok(domain)
+    }
+
+    fn get(db: &Database, query: &Self::ID) -> Result<Self> {
+        use crate::schema::breaches::dsl::*;
+
+        let breach = breaches.filter(value.eq(query))
+            .first::<Self>(db.db())?;
+
+        Ok(breach)
+    }
+
+    fn get_opt(db: &Database, query: &Self::ID) -> Result<Option<Self>> {
+        use crate::schema::breaches::dsl::*;
+
+        let breach = breaches.filter(value.eq(query))
+            .first::<Self>(db.db())
+            .optional()?;
+
+        Ok(breach)
+    }
+}
+
+impl Scopable for Breach {
+    fn scoped(&self) -> bool {
+        !self.unscoped
+    }
+
+    fn scope(db: &Database, filter: &Filter) -> Result<usize> {
+        use crate::schema::breaches::dsl::*;
+
+        diesel::update(breaches.filter(filter.sql()))
+            .set(unscoped.eq(false))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+
+    fn noscope(db: &Database, filter: &Filter) -> Result<usize> {
+        use crate::schema::breaches::dsl::*;
+
+        diesel::update(breaches.filter(filter.sql()))
+            .set(unscoped.eq(true))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+}
+
+impl Breach {
+    fn emails(&self, db: &Database) -> Result<Vec<(Email, Option<String>)>> {
+        use std::result;
+
+        let email_id_pws = BreachEmail::belonging_to(self)
+            .select((breach_emails::email_id, breach_emails::password))
+            .load::<(i32, Option<String>)>(db.db())?;
+
+        email_id_pws.into_iter()
+            .map(|(email_id, password)| {
+                emails::table
+                    .filter(emails::id.eq(email_id))
+                    .first::<Email>(db.db())
+                    .map(|email| (email, password))
+            })
+            .collect::<result::Result<Vec<_>, _>>()
+            .map_err(Error::from)
+    }
+}
+
+pub struct PrintableBreach {
+    value: String,
+}
+
+impl fmt::Display for PrintableBreach {
+    fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
+        write!(w, "{:?}", self.value)
+    }
+}
+
+impl Printable<PrintableBreach> for Breach {
+    fn printable(&self, _db: &Database) -> Result<PrintableBreach> {
+        Ok(PrintableBreach {
+            value: self.value.to_string(),
+        })
+    }
+}
+
+pub struct EmailWithPassword {
+    email: PrintableEmail,
+    password: Option<String>,
+}
+
+impl fmt::Display for EmailWithPassword {
+    fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
+        write!(w, "{}", self.email)?;
+        if let Some(password) = &self.password {
+            write!(w, " ({:?})", password)?;
+        }
+        Ok(())
+    }
+}
+
+pub struct DetailedBreach {
+    id: i32,
+    value: String,
+    emails: Vec<EmailWithPassword>,
+    unscoped: bool,
+}
+
+impl DisplayableDetailed for DetailedBreach {
+    #[inline]
+    fn scoped(&self) -> bool {
+        !self.unscoped
+    }
+
+    #[inline]
+    fn print(&self, w: &mut fmt::DetailFormatter) -> fmt::Result {
+        w.id(self.id)?;
+        w.debug::<Green, _>(&self.value)?;
+        Ok(())
+    }
+
+    #[inline]
+    fn children(&self, w: &mut fmt::DetailFormatter) -> fmt::Result {
+        for email in &self.emails {
+            w.child(email)?;
+        }
+        Ok(())
+    }
+}
+
+display_detailed!(DetailedBreach);
+
+impl Detailed for Breach {
+    type T = DetailedBreach;
+
+    fn detailed(&self, db: &Database) -> Result<Self::T> {
+        let emails = self.emails(db)?.into_iter()
+            .map(|(sd, password)| Ok(EmailWithPassword {
+                email: sd.printable(db)?,
+                password,
+            }))
+            .collect::<Result<_>>()?;
+
+        Ok(DetailedBreach {
+            id: self.id,
+            value: self.value.to_string(),
+            emails,
+            unscoped: self.unscoped,
+        })
+    }
+}
+
+#[derive(Insertable)]
+#[table_name="breaches"]
+pub struct NewBreach<'a> {
+    pub value: &'a str,
+}
+
+impl<'a> InsertableStruct<Breach> for NewBreach<'a> {
+    fn value(&self) -> &str {
+        self.value
+    }
+
+    fn insert(&self, db: &Database) -> Result<()> {
+        diesel::insert_into(breaches::table)
+            .values(self)
+            .execute(db.db())?;
+        Ok(())
+    }
+}
+
+impl<'a> Upsertable<Breach> for NewBreach<'a> {
+    type Update = NullUpdate;
+
+    fn upsert(self, existing: &Breach) -> Self::Update {
+        Self::Update {
+            id: existing.id,
+        }
+    }
+}
+
+#[derive(Debug, Insertable, Serialize, Deserialize)]
+#[table_name="breaches"]
+pub struct NewBreachOwned {
+    pub value: String,
+}
+
+impl Printable<PrintableBreach> for NewBreachOwned {
+    fn printable(&self, _db: &Database) -> Result<PrintableBreach> {
+        Ok(PrintableBreach {
+            value: self.value.to_string(),
+        })
+    }
+}
+
+pub type InsertBreach = NewBreachOwned;
+
+impl LuaInsertToNewOwned for InsertBreach {
+    type Target = NewBreachOwned;
+
+    fn try_into_new(self) -> Result<NewBreachOwned> {
+        Ok(self)
+    }
+}
diff --git a/src/models/breach_email.rs b/src/models/breach_email.rs
new file mode 100644
index 00000000..b5acee97
--- /dev/null
+++ b/src/models/breach_email.rs
@@ -0,0 +1,217 @@
+use crate::errors::*;
+use diesel;
+use diesel::prelude::*;
+use crate::models::*;
+
+
+#[derive(Identifiable, Queryable, Associations, Serialize, Deserialize)]
+#[belongs_to(Breach)]
+#[belongs_to(Email)]
+#[table_name="breach_emails"]
+pub struct BreachEmail {
+    pub id: i32,
+    pub breach_id: i32,
+    pub email_id: i32,
+    pub password: Option<String>,
+}
+
+impl Model for BreachEmail {
+    type ID = (i32, i32, Option<String>);
+
+    fn to_string(&self) -> String {
+        unimplemented!("BreachEmail can not be printed")
+    }
+
+    fn list(db: &Database) -> Result<Vec<Self>> {
+        use crate::schema::breach_emails::dsl::*;
+
+        let results = breach_emails.load::<Self>(db.db())?;
+
+        Ok(results)
+    }
+
+    fn filter(db: &Database, filter: &Filter) -> Result<Vec<Self>> {
+        use crate::schema::breach_emails::dsl::*;
+
+        let query = breach_emails.filter(filter.sql());
+        let results = query.load::<Self>(db.db())?;
+
+        Ok(results)
+    }
+
+    fn delete(db: &Database, filter: &Filter) -> Result<usize> {
+        use crate::schema::breach_emails::dsl::*;
+
+        diesel::delete(breach_emails.filter(filter.sql()))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+
+    fn delete_id(db: &Database, my_id: i32) -> Result<usize> {
+        use crate::schema::breach_emails::dsl::*;
+
+        diesel::delete(breach_emails.filter(id.eq(my_id)))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+
+    fn id(&self) -> i32 {
+        self.id
+    }
+
+    fn by_id(db: &Database, my_id: i32) -> Result<Self> {
+        use crate::schema::breach_emails::dsl::*;
+
+        let breach_email = breach_emails.filter(id.eq(my_id))
+            .first::<Self>(db.db())?;
+
+        Ok(breach_email)
+    }
+
+    fn get(db: &Database, query: &Self::ID) -> Result<Self> {
+        use crate::schema::breach_emails::dsl::*;
+
+        let (my_breach_id, my_email_id, my_password) = query;
+
+        let query = breach_emails.filter(breach_id.eq(my_breach_id))
+                                 .filter(email_id.eq(my_email_id));
+        let breach_email = if let Some(my_password) = my_password {
+           query
+               .filter(password.is_null().or(password.eq(my_password)))
+               .first::<Self>(db.db())?
+        } else {
+           query
+               .first::<Self>(db.db())?
+        };
+
+        Ok(breach_email)
+    }
+
+    fn get_opt(db: &Database, query: &Self::ID) -> Result<Option<Self>> {
+        use crate::schema::breach_emails::dsl::*;
+
+        let (my_breach_id, my_email_id, my_password) = query;
+
+        let query = breach_emails.filter(breach_id.eq(my_breach_id))
+                                 .filter(email_id.eq(my_email_id));
+        let breach_email = if let Some(my_password) = my_password {
+           query
+               .filter(password.is_null().or(password.eq(my_password)))
+               .first::<Self>(db.db())
+               .optional()?
+        } else {
+           query
+               .first::<Self>(db.db())
+               .optional()?
+        };
+
+        Ok(breach_email)
+    }
+}
+
+impl BreachEmail {
+    pub fn breach(&self, db: &Database) -> Result<Breach> {
+        Breach::by_id(db, self.breach_id)
+    }
+}
+
+pub struct PrintableBreachEmail {
+    breach: String,
+    email: String,
+}
+
+impl fmt::Display for PrintableBreachEmail {
+    fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
+        write!(w, "{:?} -> {:?}", self.breach, self.email)
+    }
+}
+
+impl Printable<PrintableBreachEmail> for BreachEmail {
+    fn printable(&self, db: &Database) -> Result<PrintableBreachEmail> {
+        let breach = Breach::by_id(db, self.breach_id)?;
+        let email = Email::by_id(db, self.email_id)?;
+        Ok(PrintableBreachEmail {
+            breach: breach.value.to_string(),
+            email: email.value.to_string(),
+        })
+    }
+}
+
+#[derive(Insertable)]
+#[table_name="breach_emails"]
+pub struct NewBreachEmail<'a> {
+    pub breach_id: i32,
+    pub email_id: i32,
+    pub password: Option<&'a String>,
+}
+
+impl<'a> Upsertable<BreachEmail> for NewBreachEmail<'a> {
+    type Update = BreachEmailUpdate;
+
+    fn upsert(self, existing: &BreachEmail) -> Self::Update {
+        Self::Update {
+            id: existing.id,
+            password: Self::upsert_str(self.password, &existing.password),
+        }
+    }
+}
+
+#[derive(Debug, Insertable, Serialize, Deserialize)]
+#[table_name="breach_emails"]
+pub struct NewBreachEmailOwned {
+    pub breach_id: i32,
+    pub email_id: i32,
+    pub password: Option<String>,
+}
+
+impl Printable<PrintableBreachEmail> for NewBreachEmailOwned {
+    fn printable(&self, db: &Database) -> Result<PrintableBreachEmail> {
+        let breach = Breach::by_id(db, self.breach_id)?;
+        let email = Email::by_id(db, self.email_id)?;
+        Ok(PrintableBreachEmail {
+            breach: breach.value.to_string(),
+            email: email.value.to_string(),
+        })
+    }
+}
+
+pub type InsertBreachEmail = NewBreachEmailOwned;
+
+impl LuaInsertToNewOwned for InsertBreachEmail {
+    type Target = NewBreachEmailOwned;
+
+    fn try_into_new(self) -> Result<NewBreachEmailOwned> {
+        Ok(self)
+    }
+}
+
+#[derive(Identifiable, AsChangeset, Serialize, Deserialize, Debug)]
+#[table_name="breach_emails"]
+pub struct BreachEmailUpdate {
+    pub id: i32,
+    pub password: Option<String>,
+}
+
+impl Upsert for BreachEmailUpdate {
+    fn is_dirty(&self) -> bool {
+        self.password.is_some()
+    }
+
+    fn generic(self) -> Update {
+        Update::BreachEmail(self)
+    }
+
+    fn apply(&self, db: &Database) -> Result<i32> {
+        db.update_breach_email(self)
+    }
+}
+
+impl Updateable<BreachEmail> for BreachEmailUpdate {
+    fn changeset(&mut self, existing: &BreachEmail) {
+        Self::clear_if_equal(&mut self.password, &existing.password);
+    }
+
+    fn fmt(&self, updates: &mut Vec<String>) {
+        Self::push_value(updates, "password", &self.password);
+    }
+}
diff --git a/src/models/email.rs b/src/models/email.rs
index c4fb8455..716cc191 100644
--- a/src/models/email.rs
+++ b/src/models/email.rs
@@ -116,6 +116,26 @@ impl Scopable for Email {
     }
 }
 
+impl Email {
+    fn breaches(&self, db: &Database) -> Result<Vec<(Breach, Option<String>)>> {
+        use std::result;
+
+        let breach_id_pws = BreachEmail::belonging_to(self)
+            .select((breach_emails::breach_id, breach_emails::password))
+            .load::<(i32, Option<String>)>(db.db())?;
+
+        breach_id_pws.into_iter()
+            .map(|(breach_id, password)| {
+                breaches::table
+                    .filter(breaches::id.eq(breach_id))
+                    .first::<Breach>(db.db())
+                    .map(|breach| (breach, password))
+            })
+            .collect::<result::Result<Vec<_>, _>>()
+            .map_err(Error::from)
+    }
+}
+
 pub struct PrintableEmail {
     value: String,
 }
@@ -134,9 +154,25 @@ impl Printable<PrintableEmail> for Email {
     }
 }
 
+pub struct BreachWithPassword {
+    breach: PrintableBreach,
+    password: Option<String>,
+}
+
+impl fmt::Display for BreachWithPassword {
+    fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
+        write!(w, "{}", self.breach)?;
+        if let Some(password) = &self.password {
+            write!(w, " ({:?})", password)?;
+        }
+        Ok(())
+    }
+}
+
 pub struct DetailedEmail {
     id: i32,
     value: String,
+    breaches: Vec<BreachWithPassword>,
     unscoped: bool,
     valid: Option<bool>,
 }
@@ -166,7 +202,10 @@ impl DisplayableDetailed for DetailedEmail {
     }
 
     #[inline]
-    fn children(&self, _w: &mut fmt::DetailFormatter) -> fmt::Result {
+    fn children(&self, w: &mut fmt::DetailFormatter) -> fmt::Result {
+        for breach in &self.breaches {
+            w.child(breach)?;
+        }
         Ok(())
     }
 }
@@ -176,10 +215,18 @@ display_detailed!(DetailedEmail);
 impl Detailed for Email {
     type T = DetailedEmail;
 
-    fn detailed(&self, _db: &Database) -> Result<Self::T> {
+    fn detailed(&self, db: &Database) -> Result<Self::T> {
+        let breaches = self.breaches(db)?.into_iter()
+            .map(|(sd, password)| Ok(BreachWithPassword {
+                breach: sd.printable(db)?,
+                password,
+            }))
+            .collect::<Result<_>>()?;
+
         Ok(DetailedEmail {
             id: self.id,
             value: self.value.to_string(),
+            breaches,
             unscoped: self.unscoped,
             valid: self.valid,
         })
diff --git a/src/models/mod.rs b/src/models/mod.rs
index 0921752e..3904c03f 100644
--- a/src/models/mod.rs
+++ b/src/models/mod.rs
@@ -17,23 +17,40 @@ pub enum Insert {
     Network(NewNetworkOwned),
     NetworkDevice(NewNetworkDeviceOwned),
     Account(NewAccountOwned),
+    Breach(NewBreachOwned),
+    BreachEmail(NewBreachEmailOwned),
 }
 
 impl Insert {
-    pub fn value(&self) -> &str {
-        match self {
-            Insert::Domain(x) => &x.value,
-            Insert::Subdomain(x) => &x.value,
-            Insert::IpAddr(x) => &x.value,
-            Insert::SubdomainIpAddr(_x) => unimplemented!("SubdomainIpAddr doesn't have value field"),
-            Insert::Url(x) => &x.value,
-            Insert::Email(x) => &x.value,
-            Insert::PhoneNumber(x) => &x.value,
-            Insert::Device(x) => &x.value,
-            Insert::Network(x) => &x.value,
-            Insert::NetworkDevice(_x) => unimplemented!("NetworkDevice doesn't have value field"),
-            Insert::Account(x) => &x.value,
-        }
+    pub fn label(&self, db: &Database) -> Result<String> {
+        let label = match self {
+            Insert::Domain(x) => format!("{:?}", x.value),
+            Insert::Subdomain(x) => format!("{:?}", x.value),
+            Insert::IpAddr(x) => format!("{:?}", x.value),
+            Insert::SubdomainIpAddr(x) => {
+                let subdomain = Subdomain::by_id(db, x.subdomain_id)?;
+                let ipaddr = IpAddr::by_id(db, x.ip_addr_id)?;
+                format!("{:?}+{:?}", subdomain.value, ipaddr.value)
+            },
+            Insert::Url(x) => format!("{:?}", x.value),
+            Insert::Email(x) => format!("{:?}", x.value),
+            Insert::PhoneNumber(x) => format!("{:?}", x.value),
+            Insert::Device(x) => format!("{:?}", x.value),
+            Insert::Network(x) => format!("{:?}", x.value),
+            Insert::NetworkDevice(x) => {
+                let network = Network::by_id(db, x.network_id)?;
+                let device = Device::by_id(db, x.device_id)?;
+                format!("{:?}+{:?}", network.value, device.value)
+            },
+            Insert::Account(x) => format!("{:?}", x.value),
+            Insert::Breach(x) => format!("{:?}", x.value),
+            Insert::BreachEmail(x) => {
+                let breach = Breach::by_id(db, x.breach_id)?;
+                let email = Email::by_id(db, x.email_id)?;
+                format!("{:?}+{:?}", breach.value, email.value)
+            }
+        };
+        Ok(label)
     }
 
     pub fn table(&self) -> &str {
@@ -49,6 +66,8 @@ impl Insert {
             Insert::Network(_) => "networks",
             Insert::NetworkDevice(_) => "network_devices",
             Insert::Account(_) => "accounts",
+            Insert::Breach(_) => "breaches",
+            Insert::BreachEmail(_) => "breach_emails",
         }
     }
 
@@ -65,6 +84,8 @@ impl Insert {
             Insert::Network(x) => format!("Network: {}", x.printable(db)?),
             Insert::NetworkDevice(x) => x.printable(db)?.to_string(),
             Insert::Account(x) => format!("Account: {}", x.printable(db)?),
+            Insert::Breach(x) => format!("Breach: {}", x.printable(db)?),
+            Insert::BreachEmail(x) => x.printable(db)?.to_string(),
         })
     }
 }
@@ -80,6 +101,7 @@ pub enum Update {
     Network(NetworkUpdate),
     NetworkDevice(NetworkDeviceUpdate),
     Account(AccountUpdate),
+    BreachEmail(BreachEmailUpdate),
 }
 
 impl Update {
@@ -94,6 +116,7 @@ impl Update {
             Update::Network(update)       => update.is_dirty(),
             Update::NetworkDevice(update) => update.is_dirty(),
             Update::Account(update)       => update.is_dirty(),
+            Update::BreachEmail(update)   => update.is_dirty(),
         }
     }
 }
@@ -110,6 +133,7 @@ impl fmt::Display for Update {
             Update::Network(update)       => write!(w, "{}", update.to_string()),
             Update::NetworkDevice(update) => write!(w, "{}", update.to_string()),
             Update::Account(update)       => write!(w, "{}", update.to_string()),
+            Update::BreachEmail(update)   => write!(w, "{}", update.to_string()),
         }
     }
 }
@@ -318,3 +342,9 @@ pub use self::network_device::*;
 
 mod account;
 pub use self::account::*;
+
+mod breach;
+pub use self::breach::*;
+
+mod breach_email;
+pub use self::breach_email::*;
diff --git a/src/models/network.rs b/src/models/network.rs
index 5d9fc11f..9fb31194 100644
--- a/src/models/network.rs
+++ b/src/models/network.rs
@@ -3,7 +3,6 @@ use crate::fmt::colors::*;
 use diesel;
 use diesel::prelude::*;
 use crate::models::*;
-use std::result;
 
 
 #[derive(Identifiable, Queryable, Serialize, Deserialize, PartialEq, Debug)]
@@ -119,16 +118,11 @@ impl Scopable for Network {
 
 impl Network {
     fn devices(&self, db: &Database) -> Result<Vec<Device>> {
-        let device_ids = NetworkDevice::belonging_to(self)
-            .select(network_devices::device_id)
-            .load::<i32>(db.db())?;
-
-        device_ids.into_iter()
-            .map(|device_id| devices::table
-                .filter(devices::id.eq(device_id))
-                .first::<Device>(db.db())
-            )
-            .collect::<result::Result<_, _>>()
+        let device_ids = NetworkDevice::belonging_to(self).select(network_devices::device_id);
+
+        devices::table
+            .filter(devices::id.eq_any(device_ids))
+            .load::<Device>(db.db())
             .map_err(Error::from)
     }
 }
diff --git a/src/runtime/db.rs b/src/runtime/db.rs
index 5e39be56..499f1a55 100644
--- a/src/runtime/db.rs
+++ b/src/runtime/db.rs
@@ -53,6 +53,12 @@ fn into_insert(family: Family, object: LuaJsonValue) -> Result<Insert> {
         Family::Account => {
             Insert::Account(try_into_new::<InsertAccount>(object)?)
         },
+        Family::Breach => {
+            Insert::Breach(try_into_new::<InsertBreach>(object)?)
+        },
+        Family::BreachEmail => {
+            Insert::BreachEmail(try_into_new::<InsertBreachEmail>(object)?)
+        },
     };
     Ok(obj)
 }
@@ -128,7 +134,7 @@ pub fn db_update(lua: &mut hlua::Lua, state: Arc<State>) {
                 .map(|(id, v, u)| (id, v, Update::Subdomain(u))),
             Family::IpAddr => gen_changeset::<IpAddr, IpAddrUpdate>(object, update)
                 .map(|(id, v, u)| (id, v, Update::IpAddr(u))),
-            Family::SubdomainIpAddr => bail!("Unsupported operation"),
+            Family::SubdomainIpAddr => bail!("Subdomain-IpAddr doesn't have mutable fields"),
             Family::Url => gen_changeset::<Url, UrlUpdate>(object, update)
                 .map(|(id, v, u)| (id, v, Update::Url(u))),
             Family::Email => gen_changeset::<Email, EmailUpdate>(object, update)
@@ -143,6 +149,9 @@ pub fn db_update(lua: &mut hlua::Lua, state: Arc<State>) {
                 .map(|(id, v, u)| (id, v, Update::NetworkDevice(u))),
             Family::Account => gen_changeset::<Account, AccountUpdate>(object, update)
                 .map(|(id, v, u)| (id, v, Update::Account(u))),
+            Family::Breach => bail!("Breach doesn't have mutable fields"),
+            Family::BreachEmail => gen_changeset::<BreachEmail, BreachEmailUpdate>(object, update)
+                .map(|(id, v, u)| (id, v, Update::BreachEmail(u))),
         };
 
         let (id, value, update) = update
diff --git a/src/schema.rs b/src/schema.rs
index d51b0940..35b9dd1a 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -12,6 +12,23 @@ table! {
     }
 }
 
+table! {
+    breach_emails (id) {
+        id -> Integer,
+        breach_id -> Integer,
+        email_id -> Integer,
+        password -> Nullable<Text>,
+    }
+}
+
+table! {
+    breaches (id) {
+        id -> Integer,
+        value -> Text,
+        unscoped -> Bool,
+    }
+}
+
 table! {
     devices (id) {
         id -> Integer,
@@ -141,6 +158,8 @@ table! {
     }
 }
 
+joinable!(breach_emails -> breaches (breach_id));
+joinable!(breach_emails -> emails (email_id));
 joinable!(network_devices -> devices (device_id));
 joinable!(network_devices -> networks (network_id));
 joinable!(subdomain_ipaddrs -> ipaddrs (ip_addr_id));
@@ -150,6 +169,8 @@ joinable!(urls -> subdomains (subdomain_id));
 
 allow_tables_to_appear_in_same_query!(
     accounts,
+    breach_emails,
+    breaches,
     devices,
     domains,
     emails,
diff --git a/src/worker.rs b/src/worker.rs
index 9356a268..db20237f 100644
--- a/src/worker.rs
+++ b/src/worker.rs
@@ -153,7 +153,15 @@ impl DatabaseEvent {
                 }
 
                 // TODO: replace id with actual object(?)
-                spinner.log(&format!("Updating {:?} ({})", object.value(), update));
+                match object.label(&db) {
+                    Ok(label) => {
+                        spinner.log(&format!("Updating {} ({})", label, update));
+                    },
+                    Err(err) => {
+                        // TODO: this should be unreachable
+                        spinner.error(&format!("Failed to get label for {:?}: {:?}", object, err));
+                    },
+                }
                 Ok(Some(id))
             },
             Ok(Some((DbChange::None, id))) => {


Test patch: