From e3a05548236d78d90bc0c5a2ba6c2b7c8ad46faa Mon Sep 17 00:00:00 2001 From: SeanOMik Date: Sat, 6 May 2023 19:24:03 -0400 Subject: [PATCH] Implement user auth for pushing and pulling images --- Cargo.lock | 17 +++- Cargo.toml | 1 + docs/database.md | 23 +++++ src/api/auth.rs | 38 +++---- src/api/blobs.rs | 31 +++++- src/api/catalog.rs | 2 +- src/api/manifests.rs | 49 +++++++-- src/api/mod.rs | 3 +- src/api/tags.rs | 2 +- src/api/uploads.rs | 60 +++++++---- src/auth_storage.rs | 54 +++++++--- src/config.rs | 16 ++- src/database/mod.rs | 173 ++++++++++++++++++++++++++++++-- src/database/schemas/schema.sql | 37 ++++++- src/dto/mod.rs | 24 +++++ src/dto/user.rs | 109 +++++++++++++++++++- src/main.rs | 14 --- 17 files changed, 555 insertions(+), 98 deletions(-) create mode 100644 docs/database.md diff --git a/Cargo.lock b/Cargo.lock index 198259f..0e42bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ checksum = "113713495a32dd0ab52baf5c10044725aa3aec00b31beda84218e469029b72a3" dependencies = [ "async-trait", "axum-core", - "bitflags", + "bitflags 1.3.2", "bytes", "futures-util", "http", @@ -215,6 +215,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a6904aef64d73cf10ab17ebace7befb918b82164785cb89907993be7f83813" + [[package]] name = "block-buffer" version = "0.9.0" @@ -311,7 +317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca505fd2c00136e0d0cd34bcd8b6bd0b59d5779aab396054b716334230c1c" dependencies = [ "atty", - "bitflags", + "bitflags 1.3.2", "clap_derive", "clap_lex", "once_cell", @@ -1077,6 +1083,7 @@ dependencies = [ "axum-auth", "axum-macros", "bcrypt", + "bitflags 2.2.1", "bytes", "chrono", "clap", @@ -1329,7 +1336,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -1594,7 +1601,7 @@ checksum = "fa8241483a83a3f33aa5fff7e7d9def398ff9990b2752b6c6112b83c6d246029" dependencies = [ "ahash", "atoi", - "bitflags", + "bitflags 1.3.2", "byteorder", "bytes", "crc", @@ -1892,7 +1899,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" dependencies = [ - "bitflags", + "bitflags 1.3.2", "bytes", "futures-core", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index a92b21c..0d524d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,3 +50,4 @@ hmac = "0.12.1" sha2 = "0.10.6" rand = "0.8.5" bcrypt = "0.14.0" +bitflags = "2.2.1" diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..c7478ec --- /dev/null +++ b/docs/database.md @@ -0,0 +1,23 @@ +## user_permissions table +The field `repository_custom_scope` is an integer created by using bitwise operations. + +* pull - `0b0001` +* push - `0b0010` +* edit - `0b0111` +* admin - `0b1111` + +### Predefined user permission scopes: +* limited + * pull image +* developer + * pull and push image +* master + * retag images +* project_admin + * configure repository access + +## user_registry_permissions + +user_type: +* regular user = 0 +* admin = 1 \ No newline at end of file diff --git a/src/api/auth.rs b/src/api/auth.rs index 7d9a51c..4ed39d6 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -2,7 +2,7 @@ use std::{sync::Arc, collections::{HashMap, BTreeMap}, time::{SystemTime, UNIX_E use axum::{extract::{Query, State}, response::{IntoResponse, Response}, http::{StatusCode, header}, Form}; use axum_auth::AuthBasic; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Utc, Duration}; use serde::{Deserialize, Serialize}; use tracing::{debug, error, info, span, Level}; @@ -12,7 +12,7 @@ use sha2::Sha256; use rand::Rng; -use crate::{dto::scope::Scope, app_state::AppState}; +use crate::{dto::{scope::Scope, user::{UserAuth, TokenInfo}}, app_state::AppState}; use crate::database::Database; #[derive(Deserialize, Debug)] @@ -41,13 +41,12 @@ pub struct AuthResponse { issued_at: String, } -fn create_jwt_token(account: String) -> anyhow::Result { +/// In the returned UserToken::user, only the username is specified +fn create_jwt_token(account: &str) -> anyhow::Result { let key: Hmac = Hmac::new_from_slice(b"some-secret")?; - let now = SystemTime::now(); - let now_secs = now - .duration_since(UNIX_EPOCH)? - .as_secs(); + let now = chrono::offset::Utc::now(); + let now_secs = now.timestamp(); // Construct the claims for the token let mut claims = BTreeMap::new(); @@ -55,19 +54,23 @@ fn create_jwt_token(account: String) -> anyhow::Result { claims.insert("subject", &account); //claims.insert("audience", auth.service); - let notbefore = format!("{}", now_secs - 10); - let issuedat = format!("{}", now_secs); + let not_before = format!("{}", now_secs - 10); + let issued_at = format!("{}", now_secs); let expiration = format!("{}", now_secs + 20); - claims.insert("notbefore", ¬before); - claims.insert("issuedat", &issuedat); + claims.insert("notbefore", ¬_before); + claims.insert("issuedat", &issued_at); claims.insert("expiration", &expiration); // TODO: 20 seconds expiry for testing + let issued_at = now; + let expiration = now + Duration::seconds(20); + // Create a randomized jwtid let mut rng = rand::thread_rng(); let jwtid = format!("{}", rng.gen::()); claims.insert("jwtid", &jwtid); - Ok(claims.sign_with_key(&key)?) + let token_str = claims.sign_with_key(&key)?; + Ok(TokenInfo::new(token_str, expiration, issued_at)) } pub async fn auth_basic_get(basic_auth: Option, state: State>, Query(params): Query>, form: Option>) -> Response { @@ -142,6 +145,7 @@ pub async fn auth_basic_get(basic_auth: Option, state: State, state: State, state: State, state: State, state: State>, Extension(auth): Extension) -> Response { + // Check if the user has permission to pull, or that the repository is public + let database = &state.database; + if !does_user_have_permission(database, auth.user.username, name.clone(), Permission::PULL).await.unwrap() + && !database.get_repository_visibility(&name).await.unwrap() + .and_then(|v| Some(v == RepositoryVisibility::Public)) + .unwrap_or_else(|| false) { + + return get_unauthenticated_response(&state.config); + } + drop(database); -pub async fn digest_exists_head(Path((_name, layer_digest)): Path<(String, String)>, state: State>) -> Response { let storage = state.storage.lock().await; if storage.has_digest(&layer_digest).await.unwrap() { @@ -26,7 +42,18 @@ pub async fn digest_exists_head(Path((_name, layer_digest)): Path<(String, Strin StatusCode::NOT_FOUND.into_response() } -pub async fn pull_digest_get(Path((_name, layer_digest)): Path<(String, String)>, state: State>) -> Response { +pub async fn pull_digest_get(Path((name, layer_digest)): Path<(String, String)>, state: State>, Extension(auth): Extension) -> Response { + // Check if the user has permission to pull, or that the repository is public + let database = &state.database; + if !does_user_have_permission(database, auth.user.username, name.clone(), Permission::PULL).await.unwrap() + && !database.get_repository_visibility(&name).await.unwrap() + .and_then(|v| Some(v == RepositoryVisibility::Public)) + .unwrap_or_else(|| false) { + + return get_unauthenticated_response(&state.config); + } + drop(database); + let storage = state.storage.lock().await; if let Some(len) = storage.digest_length(&layer_digest).await.unwrap() { diff --git a/src/api/catalog.rs b/src/api/catalog.rs index 0f9eb57..42e28b9 100644 --- a/src/api/catalog.rs +++ b/src/api/catalog.rs @@ -36,7 +36,7 @@ pub async fn list_repositories(Query(params): Query, sta let last_repo = repos.last().and_then(|s| Some(s.clone())); // Construct the link header - let url = &state.config.url; + let url = &state.config.get_url(); let mut url = format!("<{}/v2/_catalog?n={}", url, limit); if let Some(last_repo) = last_repo { url += &format!("&limit={}", last_repo); diff --git a/src/api/manifests.rs b/src/api/manifests.rs index 9338b8e..46d71a8 100644 --- a/src/api/manifests.rs +++ b/src/api/manifests.rs @@ -1,26 +1,33 @@ use std::sync::Arc; +use axum::Extension; use axum::extract::{Path, State}; use axum::response::{Response, IntoResponse}; use axum::http::{StatusCode, HeaderMap, HeaderName, header}; use tracing::log::warn; use tracing::{debug, info}; +use crate::auth_storage::{does_user_have_permission, get_unauthenticated_response}; use crate::app_state::AppState; - use crate::database::Database; +use crate::dto::RepositoryVisibility; use crate::dto::digest::Digest; use crate::dto::manifest::Manifest; +use crate::dto::user::{UserAuth, Permission}; + +pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>, state: State>, Extension(auth): Extension, body: String) -> Response { + if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() { + return get_unauthenticated_response(&state.config); + } -pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>, state: State>, body: String) -> Response { // Calculate the sha256 digest for the manifest. let calculated_hash = sha256::digest(body.clone()); let calculated_digest = format!("sha256:{}", calculated_hash); let database = &state.database; - // Create the image repository and save the image manifest. - database.save_repository(&name).await.unwrap(); + // Create the image repository and save the image manifest. This repository will be private by default + database.save_repository(&name, RepositoryVisibility::Private, None).await.unwrap(); database.save_manifest(&name, &calculated_digest, &body).await.unwrap(); // If the reference is not a digest, then it must be a tag name. @@ -54,12 +61,23 @@ pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)> } } -pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>, state: State>) -> Response { +pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>, state: State>, Extension(auth): Extension) -> Response { + // Check if the user has permission to pull, or that the repository is public + let database = &state.database; + if !does_user_have_permission(database, auth.user.username, name.clone(), Permission::PULL).await.unwrap() + && !database.get_repository_visibility(&name).await.unwrap() + .and_then(|v| Some(v == RepositoryVisibility::Public)) + .unwrap_or_else(|| false) { + + return get_unauthenticated_response(&state.config); + } + drop(database); + let database = &state.database; let digest = match Digest::is_digest(&reference) { true => reference.clone(), false => { - debug!("Attempting to get manifest digest using tag (name={}, reference={})", name, reference); + debug!("Attempting to get manifest digest using tag (repository={}, reference={})", name, reference); if let Some(tag) = database.get_tag(&name, &reference).await.unwrap() { tag.manifest_digest } else { @@ -90,7 +108,18 @@ pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>, ).into_response() } -pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)>, state: State>) -> Response { +pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)>, state: State>, Extension(auth): Extension) -> Response { + // Check if the user has permission to pull, or that the repository is public + let database = &state.database; + if !does_user_have_permission(database, auth.user.username, name.clone(), Permission::PULL).await.unwrap() + && !database.get_repository_visibility(&name).await.unwrap() + .and_then(|v| Some(v == RepositoryVisibility::Public)) + .unwrap_or_else(|| false) { + + return get_unauthenticated_response(&state.config); + } + drop(database); + // Get the digest from the reference path. let database = &state.database; let digest = match Digest::is_digest(&reference) { @@ -124,7 +153,11 @@ pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String) ).into_response() } -pub async fn delete_manifest(Path((name, reference)): Path<(String, String)>, headers: HeaderMap, state: State>) -> Response { +pub async fn delete_manifest(Path((name, reference)): Path<(String, String)>, headers: HeaderMap, state: State>, Extension(auth): Extension) -> Response { + if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() { + return get_unauthenticated_response(&state.config); + } + let _authorization = headers.get("Authorization").unwrap(); // TODO: use authorization header let database = &state.database; diff --git a/src/api/mod.rs b/src/api/mod.rs index fe400a6..85c80f7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -15,10 +15,11 @@ pub mod catalog; pub mod auth; use crate::auth_storage::AuthToken; +use crate::dto::user::UserAuth; /// https://docs.docker.com/registry/spec/api/#api-version-check /// full endpoint: `/v2/` -pub async fn version_check(Extension(AuthToken(_token)): Extension, _state: State>) -> Response { +pub async fn version_check(Extension(_auth): Extension, _state: State>) -> Response { ( StatusCode::OK, [( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0" )] diff --git a/src/api/tags.rs b/src/api/tags.rs index 971cc48..76e8282 100644 --- a/src/api/tags.rs +++ b/src/api/tags.rs @@ -37,7 +37,7 @@ pub async fn list_tags(Path((name, )): Path<(String, )>, Query(params): Query
  • ) -> impl IntoResponse { - debug!("Upload starting"); - let uuid = uuid::Uuid::new_v4(); +pub async fn start_upload_post(Path((name, )): Path<(String, )>, Extension(auth): Extension, state: State>) -> Response { + if does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() { + debug!("Upload requested"); + let uuid = uuid::Uuid::new_v4(); - debug!("Requesting upload of image {}, generated uuid: {}", name, uuid); + debug!("Requesting upload of image {}, generated uuid: {}", name, uuid); - let location = format!("/v2/{}/blobs/uploads/{}", name, uuid.to_string()); - debug!("Constructed upload url: {}", location); + let location = format!("/v2/{}/blobs/uploads/{}", name, uuid.to_string()); + debug!("Constructed upload url: {}", location); - ( - StatusCode::ACCEPTED, - [ (header::LOCATION, location) ] - ) + return ( + StatusCode::ACCEPTED, + [ (header::LOCATION, location) ] + ).into_response(); + } + + get_unauthenticated_response(&state.config) } -pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, String)>, state: State>, mut body: BodyStream) -> Response { +pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, String)>, Extension(auth): Extension, state: State>, mut body: BodyStream) -> Response { + if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() { + return get_unauthenticated_response(&state.config); + } + let storage = state.storage.lock().await; let current_size = storage.digest_length(&layer_uuid).await.unwrap(); @@ -70,7 +82,7 @@ pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, (0, written_size) }; - let full_uri = format!("{}/v2/{}/blobs/uploads/{}", &state.config.url, name, layer_uuid); + let full_uri = format!("{}/v2/{}/blobs/uploads/{}", state.config.get_url(), name, layer_uuid); ( StatusCode::ACCEPTED, [ @@ -82,7 +94,11 @@ pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, ).into_response() } -pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, String)>, Query(query): Query>, state: State>, body: Bytes) -> impl IntoResponse { +pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, String)>, Query(query): Query>, Extension(auth): Extension, state: State>, body: Bytes) -> Response { + if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() { + return get_unauthenticated_response(&state.config); + } + let digest = query.get("digest").unwrap(); let storage = state.storage.lock().await; @@ -102,18 +118,26 @@ pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, S (header::CONTENT_LENGTH, "0".to_string()), (HeaderName::from_static("docker-upload-digest"), digest.to_owned()) ] - ) + ).into_response() } -pub async fn cancel_upload_delete(Path((_name, layer_uuid)): Path<(String, String)>, state: State>) -> impl IntoResponse { +pub async fn cancel_upload_delete(Path((name, layer_uuid)): Path<(String, String)>, state: State>, Extension(auth): Extension) -> Response { + if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() { + return get_unauthenticated_response(&state.config); + } + let storage = state.storage.lock().await; storage.delete_digest(&layer_uuid).await.unwrap(); // I'm not sure what this response should be, its not specified in the registry spec. - StatusCode::OK + StatusCode::OK.into_response() } -pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, String)>, state: State>) -> impl IntoResponse { +pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, String)>, state: State>, Extension(auth): Extension) -> Response { + if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() { + return get_unauthenticated_response(&state.config); + } + let storage = state.storage.lock().await; let ending = storage.digest_length(&layer_uuid).await.unwrap().unwrap_or(0); @@ -124,5 +148,5 @@ pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, Str (header::RANGE, format!("0-{}", ending)), (HeaderName::from_static("docker-upload-digest"), layer_uuid) ] - ) + ).into_response() } \ No newline at end of file diff --git a/src/auth_storage.rs b/src/auth_storage.rs index b06aa04..964d010 100644 --- a/src/auth_storage.rs +++ b/src/auth_storage.rs @@ -1,10 +1,11 @@ use std::{collections::HashSet, ops::Deref, sync::Arc}; -use axum::{extract::State, http::{StatusCode, HeaderMap, header, HeaderName, Request}, middleware::Next, response::{Response, IntoResponse}}; +use axum::{extract::{State, Path}, http::{StatusCode, HeaderMap, header, HeaderName, Request}, middleware::Next, response::{Response, IntoResponse}}; use tracing::debug; -use crate::app_state::AppState; +use crate::{app_state::AppState, dto::user::{Permission, RegistryUserType}, config::Config}; +use crate::database::Database; /// Temporary struct for storing auth information in memory. pub struct MemoryAuthStorage { @@ -33,7 +34,7 @@ impl Deref for AuthToken { type Rejection = (StatusCode, HeaderMap); pub async fn require_auth(State(state): State>, mut request: Request, next: Next) -> Result { - let bearer = format!("Bearer realm=\"http://localhost:3000/auth\""); + let bearer = format!("Bearer realm=\"{}/auth\"", state.config.get_url()); let mut failure_headers = HeaderMap::new(); failure_headers.append(header::WWW_AUTHENTICATE, bearer.parse().unwrap()); failure_headers.append(HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".parse().unwrap()); @@ -43,7 +44,7 @@ pub async fn require_auth(State(state): State>, mut request: Re .ok_or((StatusCode::UNAUTHORIZED, failure_headers.clone()))? .to_str() .map_err(|_| (StatusCode::UNAUTHORIZED, failure_headers.clone()))? - ); // TODO: Don't unwrap + ); let token = match auth.split_once(' ') { Some((auth, token)) if auth == "Bearer" => token, @@ -53,23 +54,46 @@ pub async fn require_auth(State(state): State>, mut request: Re }; // If the token is not valid, return an unauthorized response - let auth_storage = state.auth_storage.lock().await; - if !auth_storage.valid_tokens.contains(token) { - let bearer = format!("Bearer realm=\"http://localhost:3000/auth\""); - return Ok(( + let database = &state.database; + if let Some(user) = database.verify_user_token(token.to_string()).await.unwrap() { + debug!("Authenticated user through middleware: {}", user.user.username); + + request.extensions_mut().insert(user); + + Ok(next.run(request).await) + } else { + let bearer = format!("Bearer realm=\"{}/auth\"", state.config.get_url()); + Ok(( StatusCode::UNAUTHORIZED, [ ( header::WWW_AUTHENTICATE, bearer ), ( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() ) ] - ).into_response()); - - } else { - debug!("Client successfully authenticated!"); + ).into_response()) } - drop(auth_storage); +} - request.extensions_mut().insert(AuthToken(String::from(token))); +pub async fn does_user_have_permission(database: &impl Database, username: String, repository: String, permission: Permission) -> sqlx::Result { + let allowed_to = { + match database.get_user_registry_type(username.clone()).await.unwrap() { + Some(RegistryUserType::Admin) => true, + _ => match database.get_user_repo_permissions(username, repository).await.unwrap() { + Some(perms) => if perms.has_permission(permission) { true } else { false }, + _ => false, + } + } + }; - Ok(next.run(request).await) + Ok(allowed_to) +} + +pub fn get_unauthenticated_response(config: &Config) -> Response { + let bearer = format!("Bearer realm=\"{}/auth\"", config.get_url()); + ( + StatusCode::UNAUTHORIZED, + [ + ( header::WWW_AUTHENTICATE, bearer ), + ( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() ) + ] + ).into_response() } \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 6e81455..c85e4b0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,7 @@ use std::env; pub struct Config { pub listen_address: String, pub listen_port: String, - pub url: String, + pub url: Option, } #[allow(dead_code)] @@ -37,11 +37,19 @@ impl Config { .join(Toml::file(format!("{}", path))); let mut config: Config = figment.extract()?; - - if config.url.ends_with("/") { - config.url = config.url[..config.url.len() - 1].to_string(); + if let Some(url) = config.url.as_mut() { + if url.ends_with("/") { + *url = url[..url.len() - 1].to_string(); + } } Ok(config) } + + pub fn get_url(&self) -> String { + match &self.url { + Some(u) => u.clone(), + None => format!("http://{}:{}", self.listen_address, self.listen_port) + } + } } \ No newline at end of file diff --git a/src/database/mod.rs b/src/database/mod.rs index 9b5bcb7..b758e77 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -2,9 +2,9 @@ use async_trait::async_trait; use sqlx::{Sqlite, Pool}; use tracing::debug; -use chrono::{DateTime, Utc, NaiveDateTime}; +use chrono::{DateTime, Utc, NaiveDateTime, TimeZone}; -use crate::dto::{Tag, user::User}; +use crate::dto::{Tag, user::{User, RepositoryPermissions, RegistryUserType, Permission, UserAuth, TokenInfo}, RepositoryVisibility}; #[async_trait] pub trait Database { @@ -40,8 +40,10 @@ pub trait Database { // Repository related functions + async fn has_repository(&self, repository: &str) -> sqlx::Result; + async fn get_repository_visibility(&self, repository: &str) -> anyhow::Result>; /// Create a repository - async fn save_repository(&self, repository: &str) -> sqlx::Result<()>; + async fn save_repository(&self, repository: &str, visibility: RepositoryVisibility, owning_project: Option) -> sqlx::Result<()>; /// List all repositories. /// If limit is not specified, a default limit of 1000 will be returned. async fn list_repositories(&self, limit: Option, last_repo: Option) -> sqlx::Result>; @@ -50,6 +52,11 @@ pub trait Database { /// User stuff async fn create_user(&self, username: String, email: String, password_hash: String, password_salt: String) -> sqlx::Result; async fn verify_user_login(&self, username: String, password: String) -> anyhow::Result; + async fn get_user_registry_type(&self, username: String) -> anyhow::Result>; + async fn get_user_repo_permissions(&self, username: String, repository: String) -> anyhow::Result>; + async fn get_user_registry_usertype(&self, username: String) -> anyhow::Result>; + async fn store_user_token(&self, token: String, username: String, expiry: DateTime, created_at: DateTime) -> anyhow::Result<()>; + async fn verify_user_token(&self, token: String) -> anyhow::Result>; } #[async_trait] @@ -127,6 +134,7 @@ impl Database for Pool { } async fn get_tag(&self, repository: &str, tag: &str) -> sqlx::Result> { + debug!("get tag"); let row: (String, i64, ) = match sqlx::query_as("SELECT image_manifest, last_updated FROM image_tags WHERE name = ? AND repository = ?") .bind(tag) .bind(repository) @@ -159,7 +167,7 @@ impl Database for Pool { } async fn delete_tag(&self, repository: &str, tag: &str) -> sqlx::Result<()> { - sqlx::query("DELETE FROM image_tags WHERE name = ? AND repository = ?") + sqlx::query("DELETE FROM image_tags WHERE 'name' = ? AND repository = ?") .bind(tag) .bind(repository) .execute(self).await?; @@ -224,11 +232,67 @@ impl Database for Pool { Ok(digests) } - async fn save_repository(&self, repository: &str) -> sqlx::Result<()> { - sqlx::query("INSERT INTO repositories (name) VALUES (?)") - .bind(repository) - .execute(self).await?; - + async fn has_repository(&self, repository: &str) -> sqlx::Result { + debug!("before query ig"); + let row: (u32, ) = match sqlx::query_as("SELECT COUNT(1) repositories WHERE 'name' = ?") + .bind(repository) + .fetch_one(self).await { + Ok(row) => row, + Err(e) => match e { + sqlx::Error::RowNotFound => { + return Ok(false) + }, + _ => { + return Err(e); + } + } + }; + + Ok(row.0 > 0) + } + + async fn get_repository_visibility(&self, repository: &str) -> anyhow::Result> { + let row: (u32, ) = match sqlx::query_as("SELECT visibility FROM repositories WHERE 'name' = ?") + .bind(repository) + .fetch_one(self).await { + Ok(row) => row, + Err(e) => match e { + sqlx::Error::RowNotFound => { + return Ok(None) + }, + _ => { + return Err(anyhow::Error::new(e)); + } + } + }; + + Ok(Some(RepositoryVisibility::try_from(row.0)?)) + } + + async fn save_repository(&self, repository: &str, visibility: RepositoryVisibility, owning_project: Option) -> sqlx::Result<()> { + // ensure that the repository was not already created + if self.has_repository(repository).await? { + debug!("repo exists"); + return Ok(()); + } + debug!("repo does not exist"); + + match owning_project { + Some(owner) => { + sqlx::query("INSERT INTO repositories (name, visibility, owning_project) VALUES (?, ?, ?)") + .bind(repository) + .bind(visibility as u32) + .bind(owner) + .execute(self).await?; + }, + None => { + sqlx::query("INSERT INTO repositories (name, visibility) VALUES (?, ?)") + .bind(repository) + .bind(visibility as u32) + .execute(self).await?; + } + } + Ok(()) } @@ -278,4 +342,95 @@ impl Database for Pool { Ok(bcrypt::verify(password, &row.0)?) } + + async fn get_user_registry_type(&self, username: String) -> anyhow::Result> { + let username = username.to_lowercase(); + + let row: (u32, ) = match sqlx::query_as("SELECT user_type FROM user_registry_permissions WHERE username = ?") + .bind(username) + .fetch_one(self).await { + Ok(row) => row, + Err(e) => match e { + sqlx::Error::RowNotFound => { + return Ok(None) + }, + _ => { + return Err(anyhow::Error::new(e)); + } + } + }; + + Ok(Some(RegistryUserType::try_from(row.0)?)) + } + + async fn get_user_repo_permissions(&self, username: String, repository: String) -> anyhow::Result> { + let username = username.to_lowercase(); + + let row: (u32, ) = match sqlx::query_as("SELECT repository_permissions FROM user_repo_permissions WHERE username = ? AND repository_name = ?") + .bind(username.clone()) + .bind(repository.clone()) + .fetch_one(self).await { + Ok(row) => row, + Err(e) => match e { + sqlx::Error::RowNotFound => { + return Ok(None) + }, + _ => { + return Err(anyhow::Error::new(e)); + } + } + }; + + let vis = self.get_repository_visibility(&repository).await?.unwrap(); + + // Also get the user type for the registry, if its admin return admin repository permissions + let utype = self.get_user_registry_usertype(username).await?.unwrap(); // unwrap should be safe + if utype == RegistryUserType::Admin { + Ok(Some(RepositoryPermissions::new(Permission::ADMIN.bits(), vis))) + } else { + Ok(Some(RepositoryPermissions::new(row.0, vis))) + } + } + + async fn get_user_registry_usertype(&self, username: String) -> anyhow::Result> { + let username = username.to_lowercase(); + let row: (u32, ) = sqlx::query_as("SELECT user_type FROM user_registry_permissions WHERE username = ?") + .bind(username) + .fetch_one(self).await?; + + Ok(Some(RegistryUserType::try_from(row.0)?)) + } + + async fn store_user_token(&self, token: String, username: String, expiry: DateTime, created_at: DateTime) -> anyhow::Result<()> { + let username = username.to_lowercase(); + let expiry = expiry.timestamp(); + let created_at = created_at.timestamp(); + sqlx::query("INSERT INTO user_tokens (token, username, expiry, created_at) VALUES (?, ?, ?, ?)") + .bind(token) + .bind(username) + .bind(expiry) + .bind(created_at) + .execute(self).await?; + + Ok(()) + } + + async fn verify_user_token(&self, token: String) -> anyhow::Result> { + let token_row: (String, i64, i64, ) = sqlx::query_as("SELECT username, expiry, created_at FROM user_tokens WHERE token = ?") + .bind(token.clone()) + .fetch_one(self).await?; + + let (username, expiry, created_at) = (token_row.0, token_row.1, token_row.2); + + let user_row: (String, ) = sqlx::query_as("SELECT email FROM users WHERE username = ?") + .bind(username.clone()) + .fetch_one(self).await?; + + let (expiry, created_at) = (Utc.timestamp_millis_opt(expiry).unwrap(), Utc.timestamp_millis_opt(created_at).unwrap()); + let user = User::new(username, user_row.0); + let token = TokenInfo::new(token, expiry, created_at); + let auth = UserAuth::new(user, token); + + Ok(Some(auth)) + } } \ No newline at end of file diff --git a/src/database/schemas/schema.sql b/src/database/schemas/schema.sql index c60f5a4..cd69708 100644 --- a/src/database/schemas/schema.sql +++ b/src/database/schemas/schema.sql @@ -1,5 +1,14 @@ +CREATE TABLE IF NOT EXISTS projects ( + name TEXT NOT NULL UNIQUE PRIMARY KEY, + -- 0 = private, 1 = public + visibility INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS repositories ( - name TEXT NOT NULL UNIQUE PRIMARY KEY + name TEXT NOT NULL UNIQUE PRIMARY KEY, + owning_project TEXT, + -- 0 = private, 1 = public + visibility INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS image_manifests ( @@ -11,13 +20,16 @@ CREATE TABLE IF NOT EXISTS image_manifests ( CREATE TABLE IF NOT EXISTS image_tags ( name TEXT NOT NULL, repository TEXT NOT NULL, + -- the image manifest for this tag image_manifest TEXT NOT NULL, + -- the epoch timestamp fo when this image tag was last updated last_updated BIGINT NOT NULL, PRIMARY KEY (name, repository) ); CREATE TABLE IF NOT EXISTS manifest_layers ( manifest TEXT NOT NULL, + -- the digest of the layer for this manifest layer_digest TEXT NOT NULL, PRIMARY KEY (manifest, layer_digest) ); @@ -25,6 +37,29 @@ CREATE TABLE IF NOT EXISTS manifest_layers ( CREATE TABLE IF NOT EXISTS users ( username TEXT NOT NULL UNIQUE PRIMARY KEY, email TEXT NOT NULL, + -- bcrypt hashed password password_hash TEXT NOT NULL, + -- the salt generated along side the password hash password_salt TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_registry_permissions ( + username TEXT NOT NULL UNIQUE PRIMARY KEY, + -- 0 = regular user, 1 = admin + user_type INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_repo_permissions ( + username TEXT NOT NULL UNIQUE PRIMARY KEY, + -- name of repository that this user has these permissions in + repository_name TEXT NOT NULL, + -- bitwised integer storing permissions + repository_permissions INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_tokens ( + token TEXT NOT NULL UNIQUE PRIMARY KEY, + username TEXT NOT NULL, + expiry BIGINT NOT NULL, + created_at BIGINT NOT NULL ); \ No newline at end of file diff --git a/src/dto/mod.rs b/src/dto/mod.rs index 323cefe..cc9886c 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -22,4 +22,28 @@ impl Tag { manifest_digest, } } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RepositoryVisibility { + Private = 0, + Public = 1 +} + +impl TryFrom for RepositoryVisibility { + type Error = anyhow::Error; + + fn try_from(value: u32) -> Result { + match value { + 0 => Ok(Self::Private), + 1 => Ok(Self::Public), + _ => Err(anyhow::anyhow!("Invalid value for RepositoryVisibility: `{}`", value)), + } + } +} + +impl Into for RepositoryVisibility { + fn into(self) -> u32 { + self as u32 + } } \ No newline at end of file diff --git a/src/dto/user.rs b/src/dto/user.rs index 44cb04e..50e3b70 100644 --- a/src/dto/user.rs +++ b/src/dto/user.rs @@ -1,6 +1,14 @@ +use std::collections::HashMap; + +use bitflags::bitflags; +use chrono::{DateTime, Utc}; + +use super::RepositoryVisibility; + +#[derive(Clone, Debug, PartialEq)] pub struct User { - username: String, - email: String, + pub username: String, + pub email: String, } impl User { @@ -10,4 +18,101 @@ impl User { email, } } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TokenInfo { + pub token: String, + pub expiry: DateTime, + pub created_at: DateTime, +} + +impl TokenInfo { + pub fn new(token: String, expiry: DateTime, created_at: DateTime) -> Self { + Self { + token, + expiry, + created_at + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct UserAuth { + pub user: User, + pub token: TokenInfo, +} + +impl UserAuth { + pub fn new(user: User, token: TokenInfo) -> Self { + Self { + user, + token, + } + } +} + +bitflags! { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct Permission: u32 { + const PULL = 0b0001; + const PUSH = 0b0010; + const EDIT = 0b0111; + const ADMIN = 0b1111; + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RepositoryPermissions { + perms: u32, + visibility: RepositoryVisibility +} + +impl RepositoryPermissions { + pub fn new(perms: u32, visibility: RepositoryVisibility) -> Self { + Self { + perms, + visibility + } + } + + /// Check if this struct has this permission, use `RepositoryPermission` + /// which has constants for the permissions. + pub fn has_permission(&self, perm: Permission) -> bool { + let perm = perm.bits(); + self.perms & perm == perm + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RegistryUserType { + Regular = 0, + Admin = 1 +} + +impl TryFrom for RegistryUserType { + type Error = anyhow::Error; + + fn try_from(value: u32) -> Result { + match value { + 0 => Ok(Self::Regular), + 1 => Ok(Self::Admin), + _ => Err(anyhow::anyhow!("Invalid value for RegistryUserType: `{}`", value)), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RegistryUser { + user_type: RegistryUserType, + repository_permissions: HashMap, +} + +impl RegistryUser { + pub fn new(user_type: RegistryUserType, repository_permissions: HashMap) -> Self { + Self { + user_type, + repository_permissions, + } + } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 9b43886..e4270db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,20 +57,6 @@ async fn change_request_paths(mut request: Request, next: Next) -> Resp next.run(request).await } -pub async fn auth_failure() -> impl IntoResponse { - let bearer = format!("Bearer realm=\"http://localhost:3000/token\""); - - ( - StatusCode::UNAUTHORIZED, - - [ - ( header::WWW_AUTHENTICATE, bearer ), - ( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() ) - ] - ).into_response() - //StatusCode::UNAUTHORIZED -} - #[tokio::main] async fn main() -> std::io::Result<()> { let pool = SqlitePoolOptions::new()