🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-09-21 2:51:52.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-09-21 3:07:38.0 +01:00:00
commit
d27847cbb9f25dde54def0b480e60bee2761da1d [patch]
tree
bb62fc0d9b6090b21cef1f6bbd243dec6536c6da
parent
c8e1cd23f2d3bfda8d8f4e6acf08dd41ba405574
download
d27847cbb9f25dde54def0b480e60bee2761da1d.tar.gz

Implement organisations functionality

Lot of polishing off needs to be done here but makes a solid start
on #18 permissions from organisations, but crate permissions can
override these permissions (and include people from outside the
organisation).

Crates are also now auto-created on publish if the user has the
CREATE_CRATE permission on the organisation 🎉

Diff

 Cargo.lock                                                     |  24 ++++++++++++++++++++++++
 chartered-db/Cargo.toml                                        |   1 +
 chartered-git/Cargo.toml                                       |   2 ++
 chartered-db/src/crates.rs                                     | 235 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 chartered-db/src/lib.rs                                        |  54 ++++++++++++++++++++++++++++++++++++++++++++++--------
 chartered-db/src/schema.rs                                     |  23 +++++++++++++++++++++++
 chartered-db/src/users.rs                                      |  11 ++++++++++-
 chartered-db/src/uuid.rs                                       |   1 +
 chartered-frontend/src/index.tsx                               |   2 +-
 chartered-git/src/main.rs                                      |  74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
 chartered-web/src/main.rs                                      |  13 ++++++-------
 migrations/2021-08-31-214501_create_crates_table/down.sql      |   4 +++-
 migrations/2021-08-31-214501_create_crates_table/up.sql        |  45 ++++++++++++++++++++++++++++++++++-----------
 chartered-frontend/src/pages/Dashboard.tsx                     |  28 +++++++++++++++++++---------
 chartered-web/src/models/crates.rs                             |  60 ------------------------------------------------------------
 chartered-web/src/models/mod.rs                                |   1 -
 chartered-frontend/src/pages/crate/CrateView.tsx               |  30 +++++++++++++++++++++++-------
 chartered-frontend/src/pages/crate/Members.tsx                 |  18 ++++++++++++++----
 chartered-web/src/endpoints/cargo_api/download.rs              |  29 ++++++++++++++++-------------
 chartered-web/src/endpoints/cargo_api/owners.rs                |  22 +++++++---------------
 chartered-web/src/endpoints/cargo_api/publish.rs               |  39 +++++++++++++++++++++++++--------------
 chartered-web/src/endpoints/cargo_api/yank.rs                  |  59 ++++++++++++++++++++++++++++-------------------------------
 chartered-web/src/endpoints/web_api/crates/info.rs             |  31 +++++++++++++------------------
 chartered-web/src/endpoints/web_api/crates/members.rs          |  65 +++++++++++++++++++++++------------------------------------------
 chartered-web/src/endpoints/web_api/crates/recently_updated.rs |  13 +++++--------
 25 files changed, 563 insertions(+), 321 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 344bbaa..e2a37e5 100644
--- a/Cargo.lock
+++ a/Cargo.lock
@@ -202,6 +202,7 @@
 "displaydoc",
 "dotenv",
 "hex",
 "http",
 "itertools",
 "option_set",
 "rand",
@@ -240,11 +241,13 @@
 "format-bytes",
 "futures",
 "hex",
 "indoc",
 "itoa",
 "log",
 "serde",
 "serde_json",
 "sha-1",
 "shlex",
 "thrussh",
 "thrussh-keys",
 "tokio",
@@ -756,6 +759,15 @@
 "tower-service",
 "tracing",
 "want",
]

[[package]]
name = "indoc"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a75aeaaef0ce18b58056d306c27b07436fbb34b8816c53094b76dd81803136"
dependencies = [
 "unindent",
]

[[package]]
@@ -1292,6 +1304,12 @@
 "digest",
 "opaque-debug",
]

[[package]]
name = "shlex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3"

[[package]]
name = "signal-hook-registry"
@@ -1625,6 +1643,12 @@
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"

[[package]]
name = "unindent"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f14ee04d9415b52b3aeab06258a3f07093182b88ba0f9b8d203f211a7a7d41c7"

[[package]]
name = "uuid"
diff --git a/chartered-db/Cargo.toml b/chartered-db/Cargo.toml
index 62e8f9b..622ebc1 100644
--- a/chartered-db/Cargo.toml
+++ a/chartered-db/Cargo.toml
@@ -15,6 +15,7 @@
diesel = { version = "1", features = ["sqlite", "r2d2", "chrono"] }
displaydoc = "0.2"
hex = "0.4"
http = "0.2"
itertools = "0.10"
option_set = "0.1"
rand = "0.8"
diff --git a/chartered-git/Cargo.toml b/chartered-git/Cargo.toml
index 041b4bd..0099cb6 100644
--- a/chartered-git/Cargo.toml
+++ a/chartered-git/Cargo.toml
@@ -20,10 +20,12 @@
format-bytes = "0.1"
futures = "0.3"
hex = "0.4"
indoc = "1.0"
itoa = "0.4"
log = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shlex = "1"
sha-1 = "0.9"
thrussh = "0.33"
thrussh-keys = "0.21"
diff --git a/chartered-db/src/crates.rs b/chartered-db/src/crates.rs
index 1e5de80..6157a79 100644
--- a/chartered-db/src/crates.rs
+++ a/chartered-db/src/crates.rs
@@ -1,18 +1,22 @@
use crate::users::{User, UserCratePermission};
use crate::users::{Organisation, User, UserCratePermission};

use super::{
    schema::{crate_versions, crates, users},
    BitwiseExpressionMethods, ConnectionPool, Result,
    coalesce,
    schema::{crate_versions, crates, organisations, users},
    users::UserCratePermissionValue as Permissions,
    BitwiseExpressionMethods, ConnectionPool, Error, Result,
};
use diesel::{insert_into, prelude::*, Associations, Identifiable, Queryable};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};

#[derive(Identifiable, Queryable, PartialEq, Eq, Hash, Debug)]
#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
#[belongs_to(Organisation)]
pub struct Crate {
    pub id: i32,
    pub name: String,
    pub organisation_id: i32,
    pub readme: Option<String>,
    pub description: Option<String>,
    pub repository: Option<String>,
@@ -20,22 +24,61 @@
    pub documentation: Option<String>,
}

macro_rules! crate_with_permissions {
    ($user_id:ident) => {
        crates::table
            .left_join(
                crate::schema::user_crate_permissions::table.on(
                    crate::schema::user_crate_permissions::dsl::user_id
                        .eq($user_id)
                        .and(crate::schema::user_crate_permissions::crate_id.eq(crates::id)),
                ),
            )
            .left_join(
                crate::schema::user_organisation_permissions::table.on(
                    crate::schema::user_organisation_permissions::user_id
                        .eq($user_id)
                        .and(
                            crate::schema::user_organisation_permissions::organisation_id
                                .eq(crates::organisation_id),
                        ),
                ),
            )
    };
}

macro_rules! select_permissions {
    () => {
        coalesce(
            crate::schema::user_crate_permissions::permissions.nullable(),
            0,
        )
        .bitwise_or(coalesce(
            crate::schema::user_organisation_permissions::permissions.nullable(),
            0,
        ))
    };
}

impl Crate {
    pub async fn all_visible_with_versions(
    pub async fn list_with_versions(
        conn: ConnectionPool,
        given_user_id: i32,
        requesting_user_id: i32,
        given_org_name: String,
    ) -> Result<HashMap<Crate, Vec<CrateVersion<'static>>>> {
        use crate::schema::organisations::dsl::{name as org_name, organisations};

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            let crate_versions = crates::table
                .inner_join(crate::schema::user_crate_permissions::table)
            let crate_versions = crate_with_permissions!(requesting_user_id)
                .inner_join(organisations)
                .filter(org_name.eq(given_org_name))
                .filter(
                    crate::schema::user_crate_permissions::permissions
                        .bitwise_and(crate::users::UserCratePermissionValue::VISIBLE.bits())
                        .ne(0),
                    select_permissions!()
                        .bitwise_and(Permissions::VISIBLE.bits())
                        .eq(Permissions::VISIBLE.bits()),
                )
                .filter(crate::schema::user_crate_permissions::dsl::user_id.eq(given_user_id))
                .inner_join(crate_versions::table)
                .select((crates::all_columns, crate_versions::all_columns))
                .load(&conn)?;
@@ -47,23 +90,26 @@

    pub async fn list_recently_updated(
        conn: ConnectionPool,
        given_user_id: i32,
    ) -> Result<Vec<(Crate, CrateVersion<'static>)>> {
        requesting_user_id: i32,
    ) -> Result<Vec<(Crate, CrateVersion<'static>, Organisation)>> {
        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            let crates = crates::table
                .inner_join(crate::schema::user_crate_permissions::table)
            let crates = crate_with_permissions!(requesting_user_id)
                .filter(
                    crate::schema::user_crate_permissions::permissions
                        .bitwise_and(crate::users::UserCratePermissionValue::VISIBLE.bits())
                        .ne(0),
                    select_permissions!()
                        .bitwise_and(Permissions::VISIBLE.bits())
                        .eq(Permissions::VISIBLE.bits()),
                )
                .filter(crate::schema::user_crate_permissions::dsl::user_id.eq(given_user_id))
                .inner_join(organisations::table)
                .inner_join(crate_versions::table)
                .order_by(crate::schema::crate_versions::dsl::id.desc())
                .select((crates::all_columns, crate_versions::all_columns))
                .select((
                    crates::all_columns,
                    crate_versions::all_columns,
                    organisations::all_columns,
                ))
                .limit(10)
                .order_by(crate::schema::crate_versions::dsl::id.desc())
                .load(&conn)?;

            Ok(crates)
@@ -71,34 +117,95 @@
        .await?
    }

    pub async fn find_by_name(conn: ConnectionPool, crate_name: String) -> Result<Option<Self>> {
        use crate::schema::crates::dsl::{crates, name};
    pub async fn find_by_name(
        conn: ConnectionPool,
        requesting_user_id: i32,
        given_org_name: String,
        given_crate_name: String,
    ) -> Result<CrateWithPermissions> {
        use crate::schema::crates::dsl::name as crate_name;
        use crate::schema::organisations::dsl::{name as org_name, organisations};

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            Ok(crates
                .filter(name.eq(crate_name))
                .first::<Crate>(&conn)
                .optional()?)
            let (crate_, permissions) = crate_with_permissions!(requesting_user_id)
                .inner_join(organisations)
                .filter(org_name.eq(given_org_name))
                .filter(crate_name.eq(given_crate_name))
                .select((crate::schema::crates::all_columns, select_permissions!()))
                .first::<(Crate, Permissions)>(&conn)
                .optional()?
                .ok_or(Error::MissingCrate)?;

            if permissions.contains(Permissions::VISIBLE) {
                Ok(CrateWithPermissions {
                    crate_,
                    permissions,
                })
            } else {
                Err(Error::MissingPermission(Permissions::VISIBLE))
            }
        })
        .await?
    }

    pub async fn versions_with_uploader(
        self: Arc<Self>,
    pub async fn create(
        conn: ConnectionPool,
    ) -> Result<Vec<(CrateVersion<'static>, User)>> {
        requesting_user_id: i32,
        given_org_name: String,
        given_crate_name: String,
    ) -> Result<CrateWithPermissions> {
        use crate::schema::organisations::dsl::{id, name as org_name, organisations};
        use crate::schema::user_organisation_permissions::dsl::{
            organisation_id, permissions, user_id,
        };

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            Ok(CrateVersion::belonging_to(&*self)
                .inner_join(users::table)
                .load::<(CrateVersion, User)>(&conn)?)
            let (org_id, perms) = organisations
                .filter(org_name.eq(given_org_name))
                .inner_join(
                    crate::schema::user_organisation_permissions::table
                        .on(organisation_id.eq(id).and(user_id.eq(requesting_user_id))),
                )
                .select((id, permissions))
                .first::<(i32, Permissions)>(&conn)?;

            if !perms.contains(Permissions::VISIBLE) {
                Err(Error::MissingPermission(Permissions::VISIBLE))
            } else if !perms.contains(Permissions::CREATE_CRATE) {
                Err(Error::MissingPermission(Permissions::CREATE_CRATE))
            } else {
                use crate::schema::crates::dsl::{crates, name, organisation_id};

                insert_into(crates)
                    .values((name.eq(&given_crate_name), organisation_id.eq(org_id)))
                    .execute(&conn)?;

                let crate_ = crates
                    .filter(name.eq(given_crate_name).and(organisation_id.eq(org_id)))
                    .select(crate::schema::crates::all_columns)
                    .first::<Crate>(&conn)?;

                Ok(CrateWithPermissions {
                    crate_,
                    permissions: perms,
                })
            }
        })
        .await?
    }
}

#[derive(Debug)]
pub struct CrateWithPermissions {
    pub crate_: Crate,
    pub permissions: Permissions,
}

impl CrateWithPermissions {
    pub async fn version(
        self: Arc<Self>,
        conn: ConnectionPool,
@@ -109,10 +216,24 @@
        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            Ok(CrateVersion::belonging_to(&*self)
            Ok(CrateVersion::belonging_to(&self.crate_)
                .filter(version.eq(crate_version))
                .get_result::<CrateVersion>(&conn)
                .optional()?)
        })
        .await?
    }

    pub async fn versions_with_uploader(
        self: Arc<Self>,
        conn: ConnectionPool,
    ) -> Result<Vec<(CrateVersion<'static>, User)>> {
        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            Ok(CrateVersion::belonging_to(&self.crate_)
                .inner_join(users::table)
                .load::<(CrateVersion, User)>(&conn)?)
        })
        .await?
    }
@@ -123,7 +244,7 @@

            let conn = conn.get()?;

            Ok(UserCratePermission::belonging_to(&*self)
            Ok(UserCratePermission::belonging_to(&self.crate_)
                .filter(
                    permissions
                        .bitwise_and(crate::users::UserCratePermissionValue::MANAGE_USERS.bits())
@@ -140,10 +261,14 @@
        self: Arc<Self>,
        conn: ConnectionPool,
    ) -> Result<Vec<(crate::users::User, crate::users::UserCratePermissionValue)>> {
        if !self.permissions.contains(Permissions::MANAGE_USERS) {
            return Err(Error::MissingPermission(Permissions::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            Ok(UserCratePermission::belonging_to(&*self)
            Ok(UserCratePermission::belonging_to(&self.crate_)
                .inner_join(crate::schema::users::dsl::users)
                .select((
                    crate::schema::users::all_columns,
@@ -160,6 +285,10 @@
        given_user_id: i32,
        given_permissions: crate::users::UserCratePermissionValue,
    ) -> Result<usize> {
        if !self.permissions.contains(Permissions::MANAGE_USERS) {
            return Err(Error::MissingPermission(Permissions::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
            use crate::schema::user_crate_permissions::dsl::{
                crate_id, permissions, user_crate_permissions, user_id,
@@ -170,7 +299,7 @@
            Ok(diesel::update(
                user_crate_permissions
                    .filter(user_id.eq(given_user_id))
                    .filter(crate_id.eq(self.id)),
                    .filter(crate_id.eq(self.crate_.id)),
            )
            .set(permissions.eq(given_permissions.bits()))
            .execute(&conn)?)
@@ -184,6 +313,10 @@
        given_user_id: i32,
        given_permissions: crate::users::UserCratePermissionValue,
    ) -> Result<usize> {
        if !self.permissions.contains(Permissions::MANAGE_USERS) {
            return Err(Error::MissingPermission(Permissions::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
            use crate::schema::user_crate_permissions::dsl::{
                crate_id, permissions, user_crate_permissions, user_id,
@@ -194,7 +327,7 @@
            Ok(diesel::insert_into(user_crate_permissions)
                .values((
                    user_id.eq(given_user_id),
                    crate_id.eq(self.id),
                    crate_id.eq(self.crate_.id),
                    permissions.eq(given_permissions.bits()),
                ))
                .execute(&conn)?)
@@ -207,6 +340,10 @@
        conn: ConnectionPool,
        given_user_id: i32,
    ) -> Result<()> {
        if !self.permissions.contains(Permissions::MANAGE_USERS) {
            return Err(Error::MissingPermission(Permissions::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
            use crate::schema::user_crate_permissions::dsl::{
                crate_id, user_crate_permissions, user_id,
@@ -217,7 +354,7 @@
            diesel::delete(
                user_crate_permissions
                    .filter(user_id.eq(given_user_id))
                    .filter(crate_id.eq(self.id)),
                    .filter(crate_id.eq(self.crate_.id)),
            )
            .execute(&conn)?;

@@ -226,6 +363,7 @@
        .await?
    }

    #[allow(clippy::too_many_arguments)]
    pub async fn publish_version(
        self: Arc<Self>,
        conn: ConnectionPool,
@@ -241,15 +379,20 @@
            size, user_id, version,
        };
        use crate::schema::crates::dsl::{
            crates, description, documentation, homepage, id, readme, repository,
            crates, description, documentation, homepage, id, name, readme, repository,
        };

        if !self.permissions.contains(Permissions::PUBLISH_VERSION) {
            return Err(Error::MissingPermission(Permissions::PUBLISH_VERSION));
        }

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            conn.transaction::<_, crate::Error, _>(|| {
                diesel::update(crates.filter(id.eq(self.id)))
                diesel::update(crates.filter(id.eq(self.crate_.id)))
                    .set((
                        name.eq(given.name),
                        description.eq(metadata.description),
                        readme.eq(metadata.readme),
                        repository.eq(metadata.repository),
@@ -260,7 +403,7 @@

                insert_into(crate_versions)
                    .values((
                        crate_id.eq(self.id),
                        crate_id.eq(self.crate_.id),
                        filesystem_object.eq(file_identifier.to_string()),
                        size.eq(file_size),
                        checksum.eq(file_checksum),
@@ -287,13 +430,17 @@
        yank: bool,
    ) -> Result<()> {
        use crate::schema::crate_versions::dsl::{crate_id, crate_versions, version, yanked};

        if !self.permissions.contains(Permissions::YANK_VERSION) {
            return Err(Error::MissingPermission(Permissions::YANK_VERSION));
        }

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            diesel::update(
                crate_versions
                    .filter(crate_id.eq(self.id))
                    .filter(crate_id.eq(self.crate_.id))
                    .filter(version.eq(given_version)),
            )
            .set(yanked.eq(yank))
diff --git a/chartered-db/src/lib.rs b/chartered-db/src/lib.rs
index 45aeffb..8648b14 100644
--- a/chartered-db/src/lib.rs
+++ a/chartered-db/src/lib.rs
@@ -1,5 +1,7 @@
#![deny(clippy::pedantic)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::doc_markdown)] // `sql_function` fails this check

macro_rules! derive_diesel_json {
    ($typ:ident$(<$lt:lifetime>)?) => {
@@ -38,8 +40,9 @@
extern crate diesel;

use diesel::{
    expression::{AsExpression, Expression},
    expression::{grouped::Grouped, AsExpression, Expression},
    r2d2::{ConnectionManager, Pool},
    sql_types::{Integer, Nullable},
};
use displaydoc::Display;
use std::sync::Arc;
@@ -54,25 +57,56 @@

#[derive(Error, Display, Debug)]
pub enum Error {
    /// Failed to initialise to database connection pool: `{0}`

    /// Failed to initialise to database connection pool

    Connection(#[from] diesel::r2d2::PoolError),
    /// Failed to run query: `{0}`

    /// Failed to run query

    Query(#[from] diesel::result::Error),
    /// Failed to complete query task: `{0}`

    /// Failed to complete query task

    TaskJoin(#[from] tokio::task::JoinError),
    /// Key parse failure: `{0}`

    KeyParse(#[from] thrussh_keys::Error),
    /// You don't have the {0:?} permission for this crate

    MissingPermission(crate::users::UserCratePermissionValue),
    /// The requested crate does not exist

    MissingCrate,
}

diesel_infix_operator!(BitwiseAnd, " & ", diesel::sql_types::Integer);
impl Error {
    #[must_use]
    pub fn status_code(&self) -> http::StatusCode {
        match self {
            Self::MissingCrate => http::StatusCode::NOT_FOUND,
            Self::MissingPermission(v)
                if v.contains(crate::users::UserCratePermissionValue::VISIBLE) =>
            {
                http::StatusCode::NOT_FOUND
            }
            Self::MissingPermission(_) => http::StatusCode::FORBIDDEN,
            Self::KeyParse(_) => http::StatusCode::BAD_REQUEST,
            _ => http::StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

sql_function!(fn coalesce(x: Nullable<Integer>, y: Integer) -> Integer);

diesel_infix_operator!(BitwiseAnd, " & ", Integer);
diesel_infix_operator!(BitwiseOr, " | ", Integer);

trait BitwiseExpressionMethods: Expression<SqlType = Integer> + Sized {
    fn bitwise_and<T: AsExpression<Integer>>(
        self,
        other: T,
    ) -> Grouped<BitwiseAnd<Self, T::Expression>> {
        Grouped(BitwiseAnd::new(self.as_expression(), other.as_expression()))
    }

trait BitwiseExpressionMethods: Expression<SqlType = diesel::sql_types::Integer> + Sized {
    fn bitwise_and<T: AsExpression<diesel::sql_types::Integer>>(
    fn bitwise_or<T: AsExpression<Integer>>(
        self,
        other: T,
    ) -> BitwiseAnd<Self, T::Expression> {
        BitwiseAnd::new(self.as_expression(), other.as_expression())
    ) -> Grouped<BitwiseOr<Self, T::Expression>> {
        Grouped(BitwiseOr::new(self.as_expression(), other.as_expression()))
    }
}

impl<T: Expression<SqlType = diesel::sql_types::Integer>> BitwiseExpressionMethods for T {}
impl<T: Expression<SqlType = Integer>> BitwiseExpressionMethods for T {}
diff --git a/chartered-db/src/schema.rs b/chartered-db/src/schema.rs
index 1b3e311..35ae527 100644
--- a/chartered-db/src/schema.rs
+++ a/chartered-db/src/schema.rs
@@ -19,6 +19,7 @@
    crates (id) {
        id -> Integer,
        name -> Text,
        organisation_id -> Integer,
        readme -> Nullable<Text>,
        description -> Nullable<Text>,
        repository -> Nullable<Text>,
@@ -28,10 +29,27 @@
}

table! {
    organisations (id) {
        id -> Integer,
        uuid -> Binary,
        name -> Text,
    }
}

table! {
    user_crate_permissions (id) {
        id -> Integer,
        user_id -> Integer,
        crate_id -> Integer,
        permissions -> Integer,
    }
}

table! {
    user_organisation_permissions (id) {
        id -> Integer,
        user_id -> Integer,
        organisation_id -> Integer,
        permissions -> Integer,
    }
}
@@ -70,8 +88,11 @@

joinable!(crate_versions -> crates (crate_id));
joinable!(crate_versions -> users (user_id));
joinable!(crates -> organisations (organisation_id));
joinable!(user_crate_permissions -> crates (crate_id));
joinable!(user_crate_permissions -> users (user_id));
joinable!(user_organisation_permissions -> organisations (organisation_id));
joinable!(user_organisation_permissions -> users (user_id));
joinable!(user_sessions -> user_ssh_keys (user_ssh_key_id));
joinable!(user_sessions -> users (user_id));
joinable!(user_ssh_keys -> users (user_id));
@@ -79,7 +100,9 @@
allow_tables_to_appear_in_same_query!(
    crate_versions,
    crates,
    organisations,
    user_crate_permissions,
    user_organisation_permissions,
    user_sessions,
    user_ssh_keys,
    users,
diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index 2fc1cef..6743bbb 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -1,5 +1,5 @@
use super::{
    schema::{user_crate_permissions, user_sessions, user_ssh_keys, users},
    schema::{organisations, user_crate_permissions, user_sessions, user_ssh_keys, users},
    uuid::SqlUuid,
    ConnectionPool, Result,
};
@@ -11,6 +11,13 @@
use thrussh_keys::PublicKeyBase64;

#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
pub struct Organisation {
    pub id: i32,
    pub uuid: SqlUuid,
    pub name: String,
}

#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
pub struct User {
    pub id: i32,
    pub uuid: SqlUuid,
@@ -279,10 +286,12 @@
        const PUBLISH_VERSION = 0b0000_0000_0000_0000_0000_0000_0000_0010;
        const YANK_VERSION    = 0b0000_0000_0000_0000_0000_0000_0000_0100;
        const MANAGE_USERS    = 0b0000_0000_0000_0000_0000_0000_0000_1000;
        const CREATE_CRATE    = 0b0000_0000_0000_0000_0000_0000_0001_0000;
    }
}

impl UserCratePermissionValue {
    #[must_use]
    pub fn names() -> &'static [&'static str] {
        Self::NAMES
    }
diff --git a/chartered-db/src/uuid.rs b/chartered-db/src/uuid.rs
index bf3771a..19d8c2e 100644
--- a/chartered-db/src/uuid.rs
+++ a/chartered-db/src/uuid.rs
@@ -9,6 +9,7 @@
pub struct SqlUuid(pub uuid::Uuid);

impl SqlUuid {
    #[must_use]
    pub fn random() -> Self {
        Self(uuid::Uuid::new_v4())
    }
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index 4d2d157..09f1e20 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -44,7 +44,7 @@
          />
          <PrivateRoute
            exact
            path="/crates/:crate/:subview?"
            path="/crates/:organisation/:crate/:subview?"
            component={() => <CrateView />}
          />
          <PrivateRoute
diff --git a/chartered-git/src/main.rs b/chartered-git/src/main.rs
index 35aaab9..26d2aaa 100644
--- a/chartered-git/src/main.rs
+++ a/chartered-git/src/main.rs
@@ -58,6 +58,7 @@
            db: self.db.clone(),
            user: None,
            user_ssh_key: None,
            organisation: None,
        }
    }
}
@@ -70,6 +71,7 @@
    db: chartered_db::ConnectionPool,
    user: Option<chartered_db::users::User>,
    user_ssh_key: Option<Arc<chartered_db::users::UserSshKey>>,
    organisation: Option<String>,
}

impl Handler {
@@ -91,6 +93,13 @@
        }
    }

    fn org_name(&self) -> Result<&str, anyhow::Error> {
        match self.organisation {
            Some(ref org) => Ok(org.as_str()),
            None => anyhow::bail!("org not set after auth"),
        }
    }

    fn user_ssh_key(&self) -> Result<&Arc<chartered_db::users::UserSshKey>, anyhow::Error> {
        match self.user_ssh_key {
            Some(ref ssh_key) => Ok(ssh_key),
@@ -136,28 +145,43 @@
        data: &[u8],
        mut session: Session,
    ) -> Self::FutureUnit {
        eprintln!("exec {:x?}", data);
        let data = match std::str::from_utf8(data) {
            Ok(data) => data,
            Err(e) => return Box::pin(futures::future::err(e.into())),
        };
        let args = shlex::split(data);

        let git_upload_pack = data.starts_with(b"git-upload-pack ");

        Box::pin(async move {
            if git_upload_pack {
                // TODO: check GIT_PROTOCOL=version=2 set
                self.write(PktLine::Data(b"version 2\n"))?;
                self.write(PktLine::Data(b"agent=chartered/0.1.0\n"))?;
                self.write(PktLine::Data(b"ls-refs=unborn\n"))?;
                self.write(PktLine::Data(b"fetch=shallow wait-for-done\n"))?;
                self.write(PktLine::Data(b"server-option\n"))?;
                self.write(PktLine::Data(b"object-info\n"))?;
                self.write(PktLine::Flush)?;
                self.flush(&mut session, channel);
            let mut args = args.into_iter().map(|v| v.into_iter()).flatten();

            if args.next().as_deref() != Some("git-upload-pack") {
                anyhow::bail!("not git-upload-pack");
            }

            if let Some(org) = args.next().filter(|v| v.as_str() != "/") {
                let org = org
                    .trim_start_matches('/')
                    .trim_end_matches('/')
                    .to_string();
                self.organisation = Some(org);
            } else {
                session.data(
                    channel,
                    CryptoVec::from_slice(b"Sorry, I have no clue who you are\r\n"),
                );
                session.extended_data(channel, 1, CryptoVec::from_slice(indoc::indoc! {b"
                    \r\nNo organisation was given in the path part of the SSH URI. A chartered registry should be defined in your .cargo/config.toml as follows:
                        [registries]
                        chartered = {{ index = \"ssh://domain.to.registry.com/my-organisation\" }}\r\n
                "}));
                session.close(channel);
            }

            // TODO: check GIT_PROTOCOL=version=2 set
            self.write(PktLine::Data(b"version 2\n"))?;
            self.write(PktLine::Data(b"agent=chartered/0.1.0\n"))?;
            self.write(PktLine::Data(b"ls-refs=unborn\n"))?;
            self.write(PktLine::Data(b"fetch=shallow wait-for-done\n"))?;
            self.write(PktLine::Data(b"server-option\n"))?;
            self.write(PktLine::Data(b"object-info\n"))?;
            self.write(PktLine::Flush)?;
            self.flush(&mut session, channel);

            Ok((self, session))
        })
@@ -256,13 +280,14 @@

            // TODO: key should be cached
            let config = format!(
                r#"{{"dl":"http://127.0.0.1:8888/a/{key}/api/v1/crates","api":"http://127.0.0.1:8888/a/{key}"}}"#,
                r#"{{"dl":"http://127.0.0.1:8888/a/{key}/o/{organisation}/api/v1/crates","api":"http://127.0.0.1:8888/a/{key}/o/{organisation}"}}"#,
                key = self
                    .user_ssh_key()?
                    .clone()
                    .get_or_insert_session(self.db.clone(), self.ip.map(|v| v.to_string()))
                    .await?
                    .session_key,
                organisation = self.org_name()?,
            );
            let config_file = PackFileEntry::Blob(config.as_bytes());

@@ -275,7 +300,12 @@

            // todo: the whole tree needs caching and then we can filter in code rather than at
            //  the database
            let tree = fetch_tree(self.db.clone(), self.user()?.id).await;
            let tree = fetch_tree(
                self.db.clone(),
                self.user()?.id,
                self.org_name()?.to_string(),
            )
            .await;
            build_tree(&mut root_tree, &mut pack_file_entries, &tree)?;

            let root_tree = PackFileEntry::Tree(root_tree);
@@ -356,13 +386,17 @@
async fn fetch_tree(
    db: chartered_db::ConnectionPool,
    user_id: i32,
    org_name: String,
) -> TwoCharTree<TwoCharTree<BTreeMap<String, String>>> {
    use chartered_db::crates::Crate;

    let mut tree: TwoCharTree<TwoCharTree<BTreeMap<String, String>>> = BTreeMap::new();

    // todo: handle files with 1/2/3 characters
    for (crate_def, versions) in Crate::all_visible_with_versions(db, user_id).await.unwrap() {
    for (crate_def, versions) in Crate::list_with_versions(db, user_id, org_name)
        .await
        .unwrap()
    {
        let mut name_chars = crate_def.name.as_bytes().iter();
        let first_dir = [*name_chars.next().unwrap(), *name_chars.next().unwrap()];
        let second_dir = [*name_chars.next().unwrap(), *name_chars.next().unwrap()];
diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs
index 1cfe97a..7e656b7 100644
--- a/chartered-web/src/main.rs
+++ a/chartered-web/src/main.rs
@@ -1,9 +1,8 @@
#![deny(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]

mod endpoints;
mod middleware;
mod models;

use axum::{
    handler::{delete, get, patch, post, put},
@@ -70,21 +69,21 @@
        axum_box_after_every_route!(Router::new().route("/login", post(endpoints::web_api::login)));

    let web_authenticated = axum_box_after_every_route!(Router::new()
        .route("/crates/:crate", get(endpoints::web_api::crates::info))
        .route("/crates/:org/:crate", get(endpoints::web_api::crates::info))
        .route(
            "/crates/:crate/members",
            "/crates/:org/:crate/members",
            get(endpoints::web_api::crates::get_members)
        )
        .route(
            "/crates/:crate/members",
            "/crates/:org/:crate/members",
            patch(endpoints::web_api::crates::update_member)
        )
        .route(
            "/crates/:crate/members",
            "/crates/:org/:crate/members",
            put(endpoints::web_api::crates::insert_member)
        )
        .route(
            "/crates/:crate/members",
            "/crates/:org/:crate/members",
            delete(endpoints::web_api::crates::delete_member)
        )
        .route(
@@ -109,7 +108,7 @@
        .route("/", get(hello_world))
        .nest("/a/:key/web/v1", web_authenticated)
        .nest("/a/-/web/v1", web_unauthenticated)
        .nest("/a/:key/api/v1", api_authenticated)
        .nest("/a/:key/o/:organisation/api/v1", api_authenticated)
        .layer(middleware_stack)
        // TODO!!!
        .layer(
diff --git a/migrations/2021-08-31-214501_create_crates_table/down.sql b/migrations/2021-08-31-214501_create_crates_table/down.sql
index 632c629..183c37f 100644
--- a/migrations/2021-08-31-214501_create_crates_table/down.sql
+++ a/migrations/2021-08-31-214501_create_crates_table/down.sql
@@ -1,6 +1,8 @@
DROP TABLE organisations;
DROP TABLE crates;
DROP TABLE crate_versions;
DROP TABLE users;
DROP TABLE user_organisation_permissions;
DROP TABLE user_crate_permissions;
DROP TABLE user_ssh_keys;
DROP TABLE user_sessions;
DROP TABLE user_crate_permissions;
diff --git a/migrations/2021-08-31-214501_create_crates_table/up.sql b/migrations/2021-08-31-214501_create_crates_table/up.sql
index 5277879..0b4d1e0 100644
--- a/migrations/2021-08-31-214501_create_crates_table/up.sql
+++ a/migrations/2021-08-31-214501_create_crates_table/up.sql
@@ -1,11 +1,22 @@
CREATE TABLE organisations (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    uuid BINARY(128) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL UNIQUE
);

INSERT INTO organisations (id, uuid, name) VALUES (1, X'936DA01F9ABD4D9D80C702AF85C822A8', "core");

CREATE TABLE crates (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    name VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    organisation_id INTEGER NOT NULL,
    readme TEXT,
    description VARCHAR(255),
    repository VARCHAR(255),
    homepage VARCHAR(255),
    documentation VARCHAR(255)
    documentation VARCHAR(255),
    UNIQUE (name, organisation_id),
    FOREIGN KEY (organisation_id) REFERENCES organisations (id)
);

CREATE TABLE crate_versions (
@@ -33,6 +44,26 @@
);

INSERT INTO users (id, uuid, username) VALUES (1, X'936DA01F9ABD4D9D80C702AF85C822A8', "admin");

CREATE TABLE user_organisation_permissions (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    organisation_id INTEGER NOT NULL,
    permissions INTEGER NOT NULL,
    UNIQUE (user_id, organisation_id),
    FOREIGN KEY (user_id) REFERENCES users (id)
    FOREIGN KEY (organisation_id) REFERENCES organisations (id)
);

CREATE TABLE user_crate_permissions (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    crate_id INTEGER NOT NULL,
    permissions INTEGER NOT NULL,
    UNIQUE (user_id, crate_id),
    FOREIGN KEY (user_id) REFERENCES users (id)
    FOREIGN KEY (crate_id) REFERENCES crates (id)
);

CREATE TABLE user_ssh_keys (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
@@ -55,14 +86,4 @@
    ip VARCHAR(255),
    FOREIGN KEY (user_id) REFERENCES users (id)
    FOREIGN KEY (user_ssh_key_id) REFERENCES user_ssh_keys (id)
);

CREATE TABLE user_crate_permissions (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    crate_id INTEGER NOT NULL,
    permissions INTEGER NOT NULL,
    UNIQUE (user_id, crate_id),
    FOREIGN KEY (user_id) REFERENCES users (id)
    FOREIGN KEY (crate_id) REFERENCES crates (id)
);
diff --git a/chartered-frontend/src/pages/Dashboard.tsx b/chartered-frontend/src/pages/Dashboard.tsx
index e8aae92..bc5deff 100644
--- a/chartered-frontend/src/pages/Dashboard.tsx
+++ a/chartered-frontend/src/pages/Dashboard.tsx
@@ -7,20 +7,23 @@
import { useAuthenticatedRequest } from "../util";

interface RecentlyUpdatedResponse {
  versions: RecentlyUpdatedResponseVersions,
  versions: RecentlyUpdatedResponseVersion[];
}

interface RecentlyUpdatedResponseVersions {
  [i: number]: { name: string, version: string, },
interface RecentlyUpdatedResponseVersion {
  name: string;
  version: string;
  organisation: string;
}

export default function Dashboard() {
  const auth = useAuth();

  const { response: recentlyUpdated, error } = useAuthenticatedRequest<RecentlyUpdatedResponse>({
    auth,
    endpoint: "crates/recently-updated",
  });
  const { response: recentlyUpdated, error } =
    useAuthenticatedRequest<RecentlyUpdatedResponse>({
      auth,
      endpoint: "crates/recently-updated",
    });

  return (
    <div className="text-white">
@@ -67,15 +70,22 @@
interface Crate {
  name: string;
  version: string;
  organisation: string;
}

function CrateCard({ crate }: { crate: Crate }) {
  return (
    <Link to={`/crates/${crate.name}`} className="text-decoration-none">
    <Link
      to={`/crates/${crate.organisation}/${crate.name}`}
      className="text-decoration-none"
    >
      <div className="card border-0 mb-2 shadow-sm">
        <div className="card-body text-black d-flex flex-row">
          <div className="flex-grow-1 align-self-center">
            <h6 className="text-primary my-0">{crate.name}</h6>
            <h6 className="text-primary my-0">
              <span className="text-secondary">{crate.organisation}/</span>
              {crate.name}
            </h6>
            <small className="text-secondary">v{crate.version}</small>
          </div>

diff --git a/chartered-web/src/models/crates.rs b/chartered-web/src/models/crates.rs
deleted file mode 100644
index 60f8b61..0000000 100644
--- a/chartered-web/src/models/crates.rs
+++ /dev/null
@@ -1,60 +1,0 @@
use axum::http::StatusCode;
use chartered_db::{
    crates::Crate,
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum CrateFetchError {
    NotFound,
    MissingPermission(chartered_db::users::UserCratePermissionValue),
    Database(#[from] chartered_db::Error),
}

impl CrateFetchError {
    pub fn status_code(&self) -> StatusCode {
        match self {
            Self::NotFound | Self::MissingPermission(Permission::VISIBLE) => StatusCode::NOT_FOUND,
            Self::MissingPermission(_) => StatusCode::FORBIDDEN,
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

impl std::fmt::Display for CrateFetchError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Self::NotFound | Self::MissingPermission(Permission::VISIBLE) => {
                write!(f, "The requested crate does not exist")
            }
            Self::MissingPermission(v) => {
                write!(f, "You don't have the {:?} permission for this crate", v)
            }
            Self::Database(_) => write!(f, "An error occurred while fetching the crate."),
        }
    }
}

pub async fn get_crate_with_permissions(
    db: ConnectionPool,
    user: Arc<User>,
    crate_name: String,
    required_permissions: &[chartered_db::users::UserCratePermissionValue],
) -> Result<Arc<Crate>, CrateFetchError> {
    let crate_ = Crate::find_by_name(db.clone(), crate_name)
        .await?
        .ok_or(CrateFetchError::NotFound)
        .map(std::sync::Arc::new)?;
    let has_permissions = user.get_crate_permissions(db, crate_.id).await?;

    for required_permission in required_permissions {
        if !has_permissions.contains(*required_permission) {
            return Err(CrateFetchError::MissingPermission(*required_permission));
        }
    }

    Ok(crate_)
}
diff --git a/chartered-web/src/models/mod.rs b/chartered-web/src/models/mod.rs
deleted file mode 100644
index 30e78d1..0000000 100644
--- a/chartered-web/src/models/mod.rs
+++ /dev/null
@@ -1,1 +1,0 @@
pub mod crates;
diff --git a/chartered-frontend/src/pages/crate/CrateView.tsx b/chartered-frontend/src/pages/crate/CrateView.tsx
index edff414..f5e299d 100644
--- a/chartered-frontend/src/pages/crate/CrateView.tsx
+++ a/chartered-frontend/src/pages/crate/CrateView.tsx
@@ -56,16 +56,16 @@

export default function SingleCrate() {
  const auth = useAuth();
  const { crate, subview } = useParams();
  const { organisation, crate, subview } = useParams();
  const currentTab: Tab | undefined = subview;

  if (!currentTab) {
    return <Redirect to={`/crates/${crate}/readme`} />
    return <Redirect to={`/crates/${organisation}/${crate}/readme`} />;
  }

  const { response: crateInfo, error } = useAuthenticatedRequest<CrateInfo>({
    auth,
    endpoint: `crates/${crate}`,
    endpoint: `crates/${organisation}/${crate}`,
  });

  if (error) {
@@ -141,12 +141,20 @@
              <div className="card-header">
                <ul className="nav nav-pills card-header-pills">
                  <li className="nav-item">
                    <NavLink to={`/crates/${crate}/readme`} className="nav-link" activeClassName="bg-primary bg-gradient active">
                    <NavLink
                      to={`/crates/${organisation}/${crate}/readme`}
                      className="nav-link"
                      activeClassName="bg-primary bg-gradient active"
                    >
                      Readme
                    </NavLink>
                  </li>
                  <li className="nav-item">
                    <NavLink to={`/crates/${crate}/versions`} className="nav-link" activeClassName="bg-primary bg-gradient active">
                    <NavLink
                      to={`/crates/${organisation}/${crate}/versions`}
                      className="nav-link"
                      activeClassName="bg-primary bg-gradient active"
                    >
                      Versions
                      <span className={`badge rounded-pill bg-danger ms-1`}>
                        {crateInfo.versions.length}
@@ -154,7 +162,11 @@
                    </NavLink>
                  </li>
                  <li className="nav-item">
                    <NavLink to={`/crates/${crate}/members`} className="nav-link" activeClassName="bg-primary bg-gradient active">
                    <NavLink
                      to={`/crates/${organisation}/${crate}/members`}
                      className="nav-link"
                      activeClassName="bg-primary bg-gradient active"
                    >
                      Members
                    </NavLink>
                  </li>
@@ -168,7 +180,11 @@
                ) : (
                  <></>
                )}
                {currentTab == "members" ? <Members crate={crate} /> : <></>}
                {currentTab == "members" ? (
                  <Members crate={crate} organisation={organisation} />
                ) : (
                  <></>
                )}
              </div>
            </div>
          </div>
diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
index e9742de..6e0fa3d 100644
--- a/chartered-frontend/src/pages/crate/Members.tsx
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -25,13 +25,19 @@
  permissions: string[];
}

export default function Members({ crate }: { crate: string }) {
export default function Members({
  organisation,
  crate,
}: {
  organisation: string;
  crate: string;
}) {
  const auth = useAuth();
  const [reload, setReload] = useState(0);
  const { response, error } = useAuthenticatedRequest<CratesMembersResponse>(
    {
      auth,
      endpoint: `crates/${crate}/members`,
      endpoint: `crates/${organisation}/${crate}/members`,
    },
    [reload]
  );
@@ -72,6 +78,7 @@
            {response.members.map((member, index) => (
              <MemberListItem
                key={index}
                organisation={organisation}
                crate={crate}
                member={member}
                prospectiveMember={false}
@@ -83,6 +90,7 @@
            {prospectiveMembers.map((member, index) => (
              <MemberListItem
                key={index}
                organisation={organisation}
                crate={crate}
                member={member}
                prospectiveMember={true}
@@ -112,12 +120,14 @@
}

function MemberListItem({
  organisation,
  crate,
  member,
  prospectiveMember,
  allowedPermissions,
  onUpdateComplete,
}: {
  organisation: string;
  crate: string;
  member: Member;
  prospectiveMember: boolean;
@@ -139,7 +149,7 @@

    try {
      let res = await fetch(
        authenticatedEndpoint(auth, `crates/${crate}/members`),
        authenticatedEndpoint(auth, `crates/${organisation}/${crate}/members`),
        {
          method: prospectiveMember ? "PUT" : "PATCH",
          headers: {
@@ -171,7 +181,7 @@

    try {
      let res = await fetch(
        authenticatedEndpoint(auth, `crates/${crate}/members`),
        authenticatedEndpoint(auth, `crates/${organisation}/${crate}/members`),
        {
          method: "DELETE",
          headers: {
diff --git a/chartered-web/src/endpoints/cargo_api/download.rs b/chartered-web/src/endpoints/cargo_api/download.rs
index ccae396..da4bb65 100644
--- a/chartered-web/src/endpoints/cargo_api/download.rs
+++ a/chartered-web/src/endpoints/cargo_api/download.rs
@@ -1,23 +1,17 @@
use crate::models::crates::get_crate_with_permissions;
use axum::extract;
use chartered_db::{
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use chartered_fs::FileSystem;
use std::{str::FromStr, sync::Arc};
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
    #[error("Failed to fetch crate file")]
    File(#[from] std::io::Error),
    #[error("The requested version does not exist for the crate")]
    NoVersion,
    #[error("{0}")]
    CrateFetch(#[from] crate::models::crates::CrateFetchError),
}

impl Error {
@@ -25,9 +19,9 @@
        use axum::http::StatusCode;

        match self {
            Self::Database(_) | Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::Database(e) => e.status_code(),
            Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::NoVersion => StatusCode::NOT_FOUND,
            Self::CrateFetch(e) => e.status_code(),
        }
    }
}
@@ -35,13 +29,22 @@
define_error_response!(Error);

pub async fn handle(
    extract::Path((_session_key, name, version)): extract::Path<(String, String, String)>,
    extract::Path((_session_key, name, organisation, version)): extract::Path<(
        String,
        String,
        String,
        String,
    )>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Vec<u8>, Error> {
    let crate_ = get_crate_with_permissions(db.clone(), user, name, &[Permission::VISIBLE]).await?;
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    let version = crate_.version(db, version).await?.ok_or(Error::NoVersion)?;
    let version = crate_with_permissions
        .version(db, version)
        .await?
        .ok_or(Error::NoVersion)?;

    let file_ref = chartered_fs::FileReference::from_str(&version.filesystem_object).unwrap();

diff --git a/chartered-web/src/endpoints/cargo_api/owners.rs b/chartered-web/src/endpoints/cargo_api/owners.rs
index 5fe7e32..35a51e4 100644
--- a/chartered-web/src/endpoints/cargo_api/owners.rs
+++ a/chartered-web/src/endpoints/cargo_api/owners.rs
@@ -1,28 +1,19 @@
use crate::models::crates::get_crate_with_permissions;
use axum::{extract, Json};
use chartered_db::{
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("{0}")]
    CrateFetch(#[from] crate::models::crates::CrateFetchError),
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::CrateFetch(e) => e.status_code(),
            Self::Database(e) => e.status_code(),
        }
    }
}
@@ -43,13 +34,14 @@
}

pub async fn handle_get(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<GetResponse>, Error> {
    let crate_ = get_crate_with_permissions(db.clone(), user, name, &[Permission::VISIBLE]).await?;
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    let users = crate_
    let users = crate_with_permissions
        .owners(db)
        .await?
        .into_iter()
diff --git a/chartered-web/src/endpoints/cargo_api/publish.rs b/chartered-web/src/endpoints/cargo_api/publish.rs
index 8adc936..72eebb0 100644
--- a/chartered-web/src/endpoints/cargo_api/publish.rs
+++ a/chartered-web/src/endpoints/cargo_api/publish.rs
@@ -1,10 +1,6 @@
use crate::models::crates::get_crate_with_permissions;
use axum::extract;
use bytes::Bytes;
use chartered_db::{
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use chartered_fs::FileSystem;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -13,10 +9,8 @@

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("{0}")]
    CrateFetch(#[from] crate::models::crates::CrateFetchError),
    Database(#[from] chartered_db::Error),
    #[error("Invalid JSON from client: {0}")]
    JsonParse(#[from] serde_json::Error),
    #[error("Invalid body")]
@@ -28,8 +22,7 @@
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::CrateFetch(e) => e.status_code(),
            Self::Database(e) => e.status_code(),
            Self::JsonParse(_) | Self::MetadataParse => StatusCode::BAD_REQUEST,
        }
    }
@@ -50,6 +43,7 @@
}

pub async fn handle(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    body: Bytes,
@@ -58,17 +52,32 @@
        parse(body.as_ref()).map_err(|_| Error::MetadataParse)?;
    let metadata: Metadata = serde_json::from_slice(metadata_bytes)?;

    let crate_ = get_crate_with_permissions(
    let crate_with_permissions = Crate::find_by_name(
        db.clone(),
        user.clone(),
        user.id,
        organisation.clone(),
        metadata.inner.name.to_string(),
        &[Permission::VISIBLE, Permission::PUBLISH_VERSION],
    )
    .await?;
    .await;

    let crate_with_permissions = match crate_with_permissions {
        Ok(v) => Arc::new(v),
        Err(chartered_db::Error::MissingCrate) => {
            let new_crate = Crate::create(
                db.clone(),
                user.id,
                organisation,
                metadata.inner.name.to_string(),
            )
            .await?;
            Arc::new(new_crate)
        }
        Err(e) => return Err(e.into()),
    };

    let file_ref = chartered_fs::Local.write(crate_bytes).await.unwrap();

    crate_
    crate_with_permissions
        .publish_version(
            db,
            user,
diff --git a/chartered-web/src/endpoints/cargo_api/yank.rs b/chartered-web/src/endpoints/cargo_api/yank.rs
index 7346aa9..58175b8 100644
--- a/chartered-web/src/endpoints/cargo_api/yank.rs
+++ a/chartered-web/src/endpoints/cargo_api/yank.rs
@@ -1,28 +1,19 @@
use crate::models::crates::get_crate_with_permissions;
use axum::{extract, Json};
use chartered_db::{
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("{0}")]
    CrateFetch(#[from] crate::models::crates::CrateFetchError),
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::CrateFetch(e) => e.status_code(),
            Self::Database(e) => e.status_code(),
        }
    }
}
@@ -35,37 +26,41 @@
}

pub async fn handle_yank(
    extract::Path((_session_key, name, version)): extract::Path<(String, String, String)>,
    extract::Path((_session_key, name, organisation, version)): extract::Path<(
        String,
        String,
        String,
        String,
    )>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<Response>, Error> {
    let crate_ = get_crate_with_permissions(
        db.clone(),
        user,
        name,
        &[Permission::VISIBLE, Permission::YANK_VERSION],
    )
    .await?;

    crate_.yank_version(db, version, true).await?;
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    crate_with_permissions
        .yank_version(db, version, true)
        .await?;

    Ok(Json(Response { ok: true }))
}

pub async fn handle_unyank(
    extract::Path((_session_key, name, version)): extract::Path<(String, String, String)>,
    extract::Path((_session_key, name, organisation, version)): extract::Path<(
        String,
        String,
        String,
        String,
    )>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<Response>, Error> {
    let crate_ = get_crate_with_permissions(
        db.clone(),
        user,
        name,
        &[Permission::VISIBLE, Permission::YANK_VERSION],
    )
    .await?;

    crate_.yank_version(db, version, false).await?;
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    crate_with_permissions
        .yank_version(db, version, false)
        .await?;

    Ok(Json(Response { ok: true }))
}
diff --git a/chartered-web/src/endpoints/web_api/crates/info.rs b/chartered-web/src/endpoints/web_api/crates/info.rs
index 8b85db6..8e3455b 100644
--- a/chartered-web/src/endpoints/web_api/crates/info.rs
+++ a/chartered-web/src/endpoints/web_api/crates/info.rs
@@ -1,11 +1,6 @@
use crate::models::crates::get_crate_with_permissions;
use axum::{body::Full, extract, response::IntoResponse, Json};
use bytes::Bytes;
use chartered_db::{
    crates::Crate,
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use chartered_types::cargo::CrateVersion;
use chrono::TimeZone;
use serde::Serialize;
@@ -14,19 +9,14 @@

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("{0}")]
    CrateFetch(#[from] crate::models::crates::CrateFetchError),
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::CrateFetch(e) => e.status_code(),
            Self::Database(e) => e.status_code(),
        }
    }
}
@@ -34,12 +24,17 @@
define_error_response!(Error);

pub async fn handle(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<axum::http::Response<Full<Bytes>>, Error> {
    let crate_ = get_crate_with_permissions(db.clone(), user, name, &[Permission::VISIBLE]).await?;
    let versions = crate_.clone().versions_with_uploader(db).await?;
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    let versions = crate_with_permissions
        .clone()
        .versions_with_uploader(db)
        .await?;

    // returning a Response instead of Json here so we don't have to close
    // every Crate/CrateVersion etc, would be easier if we just had an owned
@@ -47,13 +42,13 @@
    // diesel which requires `'static' which basically forces us to use Arc
    // if we want to keep a reference to anything ourselves.
    Ok(Json(Response {
        info: crate_.as_ref().into(),
        info: (&crate_with_permissions.crate_).into(),
        versions: versions
            .into_iter()
            .map(|(v, user)| ResponseVersion {
                size: v.size,
                created_at: chrono::Utc.from_local_datetime(&v.created_at).unwrap(),
                inner: v.into_cargo_format(&crate_),
                inner: v.into_cargo_format(&crate_with_permissions.crate_),
                uploader: user.username,
            })
            .collect(),
diff --git a/chartered-web/src/endpoints/web_api/crates/members.rs b/chartered-web/src/endpoints/web_api/crates/members.rs
index a2fbc06..4059533 100644
--- a/chartered-web/src/endpoints/web_api/crates/members.rs
+++ a/chartered-web/src/endpoints/web_api/crates/members.rs
@@ -1,6 +1,6 @@
use crate::models::crates::get_crate_with_permissions;
use axum::{extract, Json};
use chartered_db::{
    crates::Crate,
    users::{User, UserCratePermissionValue as Permission},
    uuid::Uuid,
    ConnectionPool,
@@ -25,19 +25,14 @@
}

pub async fn handle_get(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<GetResponse>, Error> {
    let crate_ = get_crate_with_permissions(
        db.clone(),
        user,
        name,
        &[Permission::VISIBLE, Permission::MANAGE_USERS],
    )
    .await?;

    let members = crate_
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    let members = crate_with_permissions
        .members(db)
        .await?
        .into_iter()
@@ -61,24 +56,19 @@
}

pub async fn handle_patch(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutOrPatchRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let crate_ = get_crate_with_permissions(
        db.clone(),
        user,
        name,
        &[Permission::VISIBLE, Permission::MANAGE_USERS],
    )
    .await?;
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    let action_user = User::find_by_uuid(db.clone(), req.user_uuid)
        .await?
        .ok_or(Error::InvalidUserId)?;

    let affected_rows = crate_
    let affected_rows = crate_with_permissions
        .update_permissions(db, action_user.id, req.permissions)
        .await?;
    if affected_rows == 0 {
@@ -89,24 +79,19 @@
}

pub async fn handle_put(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutOrPatchRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let crate_ = get_crate_with_permissions(
        db.clone(),
        user,
        name,
        &[Permission::VISIBLE, Permission::MANAGE_USERS],
    )
    .await?;
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    let action_user = User::find_by_uuid(db.clone(), req.user_uuid)
        .await?
        .ok_or(Error::InvalidUserId)?;

    crate_
    crate_with_permissions
        .insert_permissions(db, action_user.id, req.permissions)
        .await?;

@@ -119,34 +104,29 @@
}

pub async fn handle_delete(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<DeleteRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let crate_ = get_crate_with_permissions(
        db.clone(),
        user,
        name,
        &[Permission::VISIBLE, Permission::MANAGE_USERS],
    )
    .await?;
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    let action_user = User::find_by_uuid(db.clone(), req.user_uuid)
        .await?
        .ok_or(Error::InvalidUserId)?;

    crate_.delete_member(db, action_user.id).await?;
    crate_with_permissions
        .delete_member(db, action_user.id)
        .await?;

    Ok(Json(ErrorResponse { error: None }))
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("{0}")]
    CrateFetch(#[from] crate::models::crates::CrateFetchError),
    Database(#[from] chartered_db::Error),
    #[error("Permissions update conflict, user was removed as a member of the crate")]
    UpdateConflictRemoved,
    #[error("An invalid user id was given")]
@@ -158,8 +138,7 @@
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::CrateFetch(e) => e.status_code(),
            Self::Database(e) => e.status_code(),
            Self::UpdateConflictRemoved => StatusCode::CONFLICT,
            Self::InvalidUserId => StatusCode::BAD_REQUEST,
        }
diff --git a/chartered-web/src/endpoints/web_api/crates/recently_updated.rs b/chartered-web/src/endpoints/web_api/crates/recently_updated.rs
index 0b2f725..c2cec7c 100644
--- a/chartered-web/src/endpoints/web_api/crates/recently_updated.rs
+++ a/chartered-web/src/endpoints/web_api/crates/recently_updated.rs
@@ -6,19 +6,14 @@

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("{0}")]
    CrateFetch(#[from] crate::models::crates::CrateFetchError),
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::CrateFetch(e) => e.status_code(),
            Self::Database(e) => e.status_code(),
        }
    }
}
@@ -34,9 +29,10 @@
    Ok(Json(Response {
        versions: crates_with_versions
            .into_iter()
            .map(|(crate_, version)| ResponseVersion {
            .map(|(crate_, version, organisation)| ResponseVersion {
                name: crate_.name,
                version: version.version,
                organisation: organisation.name,
            })
            .collect(),
    }))
@@ -51,4 +47,5 @@
pub struct ResponseVersion {
    name: String,
    version: String,
    organisation: String,
}