diff --git a/Cargo.lock b/Cargo.lock index ce8b2811..f3f1a0e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8724,6 +8724,28 @@ dependencies = [ "utoipa", ] +[[package]] +name = "trustify-module-user" +version = "0.1.0-alpha.21" +dependencies = [ + "actix-http", + "actix-web", + "anyhow", + "sea-orm", + "sea-query", + "serde_json", + "test-context", + "test-log", + "thiserror", + "tokio", + "trustify-auth", + "trustify-common", + "trustify-entity", + "trustify-test-context", + "utoipa", + "utoipa-actix-web", +] + [[package]] name = "trustify-server" version = "0.1.0-alpha.21" @@ -8753,6 +8775,7 @@ dependencies = [ "trustify-module-ingestor", "trustify-module-storage", "trustify-module-ui", + "trustify-module-user", "trustify-test-context", "url", "urlencoding", diff --git a/Cargo.toml b/Cargo.toml index 06b5b670..16df98a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,16 +7,17 @@ members = [ "cvss", "entity", "migration", - "modules/importer", - "modules/ui", + "modules/analysis", "modules/fundamental", + "modules/graphql", + "modules/importer", "modules/ingestor", "modules/storage", - "modules/graphql", - "modules/analysis", + "modules/ui", + "modules/user", "server", - "trustd", "test-context", + "trustd", "xtask", ] @@ -167,6 +168,7 @@ trustify-module-storage = { path = "modules/storage" } trustify-module-graphql = { path = "modules/graphql" } trustify-test-context = { path = "test-context" } trustify-module-analysis = { path = "modules/analysis" } +trustify-module-user = { path = "modules/user" } # These dependencies are active during both the build time and the run time. So they are normal dependencies # as well as build-dependencies. However, we can't control feature flags for build dependencies the way we do diff --git a/entity/src/lib.rs b/entity/src/lib.rs index 1455478a..d0b671f7 100644 --- a/entity/src/lib.rs +++ b/entity/src/lib.rs @@ -28,6 +28,7 @@ pub mod sbom_package_cpe_ref; pub mod sbom_package_purl_ref; pub mod source_document; pub mod status; +pub mod user_preferences; pub mod version_range; pub mod version_scheme; pub mod versioned_purl; diff --git a/entity/src/user_preferences.rs b/entity/src/user_preferences.rs new file mode 100644 index 00000000..d43bb6ce --- /dev/null +++ b/entity/src/user_preferences.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user_preferences")] +pub struct Model { + #[sea_orm(primary_key)] + pub user_id: String, + #[sea_orm(primary_key)] + pub key: String, + + pub revision: Uuid, + pub data: serde_json::Value, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index e4358bdb..1e2d4a20 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -87,6 +87,7 @@ mod m0000670_version_cmp; mod m0000680_fix_update_deprecated_advisory; mod m0000690_alter_sbom_details; mod m0000700_advisory_add_reserved; +mod m0000710_create_user_prefs; pub struct Migrator; @@ -181,6 +182,7 @@ impl MigratorTrait for Migrator { Box::new(m0000680_fix_update_deprecated_advisory::Migration), Box::new(m0000690_alter_sbom_details::Migration), Box::new(m0000700_advisory_add_reserved::Migration), + Box::new(m0000710_create_user_prefs::Migration), ] } } diff --git a/migration/src/m0000710_create_user_prefs.rs b/migration/src/m0000710_create_user_prefs.rs new file mode 100644 index 00000000..57dd7a0b --- /dev/null +++ b/migration/src/m0000710_create_user_prefs.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(UserPreferences::Table) + .col( + ColumnDef::new(UserPreferences::UserId) + .string() + .not_null() + .to_owned(), + ) + .col( + ColumnDef::new(UserPreferences::Key) + .string() + .not_null() + .to_owned(), + ) + .col( + ColumnDef::new(UserPreferences::Revision) + .uuid() + .not_null() + .to_owned(), + ) + .col( + ColumnDef::new(UserPreferences::Data) + .json_binary() + .not_null() + .to_owned(), + ) + .primary_key( + Index::create() + .col(UserPreferences::UserId) + .col(UserPreferences::Key), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UserPreferences::Table).to_owned()) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum UserPreferences { + Table, + UserId, + Key, + Revision, + Data, +} diff --git a/modules/user/Cargo.toml b/modules/user/Cargo.toml new file mode 100644 index 00000000..9da0ded2 --- /dev/null +++ b/modules/user/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "trustify-module-user" +version.workspace = true +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +trustify-auth = { workspace = true } +trustify-common = { workspace = true } +trustify-entity = { workspace = true } + +actix-web = { workspace = true } +anyhow = { workspace = true } +sea-orm = { workspace = true, features = ["sea-query-binder", "sqlx-postgres", "runtime-tokio-rustls", "macros", "debug-print"] } +sea-query = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +utoipa = { workspace = true, features = ["actix_extras", "time", "url"] } +utoipa-actix-web = { workspace = true } + +[dev-dependencies] +trustify-test-context = { workspace = true } + +actix-http = { workspace = true } +test-context = { workspace = true } +test-log = { workspace = true, features = ["log", "trace"] } +tokio = { workspace = true, features = ["full"] } diff --git a/modules/user/README.md b/modules/user/README.md new file mode 100644 index 00000000..c7561561 --- /dev/null +++ b/modules/user/README.md @@ -0,0 +1,35 @@ +# User management + +## Store user preferences + +This will create or replace the existing value. + +```bash +http PUT localhost:8080/api/v1/userPreferences/foo key=value +``` + +## Get user preferences + +```bash +http GET localhost:8080/api/v1/userPreferences/foo +``` + +## Delete user preferences + +```bash +http DELETE localhost:8080/api/v1/userPreferences/foo +``` + +## Conditionally update user preferences + +It is possible to update the value only if the expected precondition matches. Meaning that you can provide an +etag value as received from a `GET` or `PUT`, and the update will only take place if the revision/etag value hasn't +been changed yet by another update. If it did, the request will fail with a `412 Precondition failed` status and +appropriate action would be to re-fetch the data, apply the update again, and request another store operation. This +is also called "optimistic locking." + +```bash +http PUT localhost:8080/api/v1/userPreferences/foo key=value 'If-Match:""' +``` + +**NOTE:** Be sure that the `If-Match` value is actually quoted, e.g. `If-Match:"79e5d346-876f-42b6-b0d0-51ee1be73a4c"` diff --git a/modules/user/src/endpoints.rs b/modules/user/src/endpoints.rs new file mode 100644 index 00000000..31fdf6eb --- /dev/null +++ b/modules/user/src/endpoints.rs @@ -0,0 +1,124 @@ +use crate::service::{Error, UserPreferenceService}; +use actix_web::{ + delete, get, + http::header::{self, ETag, EntityTag, IfMatch}, + put, web, HttpResponse, Responder, +}; +use trustify_auth::authenticator::user::UserDetails; +use trustify_common::{db::Database, model::Revisioned}; + +/// mount the "user" module +pub fn configure(svc: &mut utoipa_actix_web::service_config::ServiceConfig, db: Database) { + svc.app_data(web::Data::new(UserPreferenceService::new(db))) + .service(set) + .service(get) + .service(delete); +} + +#[utoipa::path( + tag = "userPreferences", + operation_id = "getUserPreferences", + params( + ("key", Path, description = "The key to the user preferences"), + ), + responses( + ( + status = 200, + description = "User preference stored under this key", + body = serde_json::Value, + headers( + ("etag" = String, description = "Revision ID") + ) + ), + (status = 404, description = "Unknown user preference key"), + ) +)] +#[get("/v1/userPreference/{key}")] +/// Get user preferences +async fn get( + service: web::Data, + key: web::Path, + user: UserDetails, +) -> Result { + Ok(match service.get(user.id, key.into_inner()).await? { + Some(Revisioned { value, revision }) => HttpResponse::Ok() + .append_header((header::ETAG, ETag(EntityTag::new_strong(revision)))) + .json(value), + None => HttpResponse::NotFound().finish(), + }) +} + +#[utoipa::path( + tag = "userPreferences", + operation_id = "setUserPreferences", + request_body = serde_json::Value, + params( + ("key", Path, description = "The key to the user preferences"), + ("if-match" = Option, Header, description = "The revision to update"), + ), + responses( + ( + status = 200, + description = "User preference stored under this key", + headers( + ("etag" = String, description = "Revision ID") + ) + ), + (status = 412, description = "The provided If-Match revision did not match the actual revision") + ) +)] +#[put("/v1/userPreference/{key}")] +/// Set user preferences +async fn set( + service: web::Data, + key: web::Path, + user: UserDetails, + web::Header(if_match): web::Header, + web::Json(data): web::Json, +) -> Result { + let revision = match &if_match { + IfMatch::Any => None, + IfMatch::Items(items) => items.first().map(|etag| etag.tag()), + }; + + let Revisioned { + value: (), + revision, + } = service + .set(user.id, key.into_inner(), revision, data) + .await?; + + Ok(HttpResponse::NoContent() + .append_header((header::ETAG, ETag(EntityTag::new_strong(revision)))) + .finish()) +} + +#[utoipa::path( + tag = "userPreferences", + operation_id = "deleteUserPreferences", + request_body = serde_json::Value, + params( + ("key", Path, description = "The key to the user preferences"), + ("if-match" = Option, Header, description = "The revision to delete"), + ), + responses( + (status = 201, description = "User preferences are deleted"), + (status = 412, description = "The provided If-Match revision did not match the actual revision") + ) +)] +#[delete("/v1/userPreference/{key}")] +/// Delete user preferences +async fn delete( + service: web::Data, + key: web::Path, + user: UserDetails, + web::Header(if_match): web::Header, +) -> Result { + let revision = match &if_match { + IfMatch::Any => None, + IfMatch::Items(items) => items.first().map(|etag| etag.tag()), + }; + + service.delete(user.id, key.into_inner(), revision).await?; + Ok(HttpResponse::NoContent().finish()) +} diff --git a/modules/user/src/lib.rs b/modules/user/src/lib.rs new file mode 100644 index 00000000..ad8476b0 --- /dev/null +++ b/modules/user/src/lib.rs @@ -0,0 +1,5 @@ +#![recursion_limit = "256"] + +pub mod endpoints; +pub mod service; +pub mod test; diff --git a/modules/user/src/service.rs b/modules/user/src/service.rs new file mode 100644 index 00000000..c7db7a9d --- /dev/null +++ b/modules/user/src/service.rs @@ -0,0 +1,169 @@ +use actix_web::{body::BoxBody, HttpResponse, ResponseError}; +use sea_orm::{ + prelude::Uuid, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, + TransactionTrait, +}; +use sea_query::{Alias, Expr, OnConflict}; +use trustify_common::{db::Database, error::ErrorInformation, model::Revisioned}; +use trustify_entity::user_preferences; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("mid air collision")] + MidAirCollision, + #[error("database error: {0}")] + Database(#[from] sea_orm::DbErr), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +impl ResponseError for Error { + fn error_response(&self) -> HttpResponse { + match self { + Error::MidAirCollision => HttpResponse::PreconditionFailed().json(ErrorInformation { + error: "MidAirCollision".into(), + message: self.to_string(), + details: None, + }), + _ => HttpResponse::InternalServerError().json(ErrorInformation { + error: "Internal".into(), + message: self.to_string(), + details: None, + }), + } + } +} + +#[derive(Clone, Debug)] +pub struct UserPreferenceService { + db: Database, +} + +impl UserPreferenceService { + pub fn new(db: Database) -> Self { + Self { db } + } + + pub async fn set( + &self, + user_id: String, + key: String, + expected_revision: Option<&str>, + data: serde_json::Value, + ) -> Result, Error> { + let next = Uuid::new_v4(); + + match expected_revision { + Some(expected_revision) => { + // if we expect a revision, just update + let result = user_preferences::Entity::update_many() + .col_expr(user_preferences::Column::Data, Expr::value(data)) + .col_expr(user_preferences::Column::Revision, Expr::value(next)) + .filter(user_preferences::Column::UserId.eq(user_id)) + .filter(user_preferences::Column::Key.eq(key)) + .filter( + user_preferences::Column::Revision + .into_expr() + .cast_as(Alias::new("text")) + .eq(expected_revision), + ) + .exec(&self.db) + .await?; + + if result.rows_affected == 0 { + // we expected a revision, but didn't find one, we don't update it, but fail + Err(Error::MidAirCollision) + } else { + Ok(Revisioned { + value: (), + revision: next.to_string(), + }) + } + } + None => { + let on_conflict = OnConflict::columns([ + user_preferences::Column::UserId, + user_preferences::Column::Key, + ]) + .values([ + (user_preferences::Column::Revision, next.into()), + (user_preferences::Column::Data, data.clone().into()), + ]) + .to_owned(); + + user_preferences::Entity::insert(user_preferences::ActiveModel { + user_id: Set(user_id), + key: Set(key), + revision: Set(next), + data: Set(data), + }) + .on_conflict(on_conflict) + .exec_without_returning(&self.db) + .await?; + + Ok(Revisioned { + value: (), + revision: next.to_string(), + }) + } + } + } + + pub async fn get( + &self, + user_id: String, + key: String, + ) -> Result>, Error> { + let result = user_preferences::Entity::find_by_id((user_id, key)) + .one(&self.db) + .await?; + + Ok(result.map(|result| Revisioned { + value: result.data, + revision: result.revision.to_string(), + })) + } + + pub async fn delete( + &self, + user_id: String, + key: String, + expected_revision: Option<&str>, + ) -> Result { + let mut delete = user_preferences::Entity::delete_many() + .filter(user_preferences::Column::UserId.eq(&user_id)) + .filter(user_preferences::Column::Key.eq(&key)); + + if let Some(revision) = expected_revision { + delete = delete.filter( + user_preferences::Column::Revision + .into_expr() + .cast_as(Alias::new("text")) + .eq(revision), + ); + } + + let tx = self.db.begin().await?; + + let result = delete.exec(&tx).await?; + + let result = if expected_revision.is_some() && result.rows_affected == 0 { + // now we need to figure out if the item wasn't there or if it was modified + if user_preferences::Entity::find_by_id((user_id, key)) + .count(&tx) + .await? + == 0 + { + Ok(false) + } else { + Err(Error::MidAirCollision) + } + } else { + Ok(result.rows_affected > 0) + }; + + tx.commit().await?; + + result + } +} diff --git a/modules/user/src/test.rs b/modules/user/src/test.rs new file mode 100644 index 00000000..abe63663 --- /dev/null +++ b/modules/user/src/test.rs @@ -0,0 +1,169 @@ +#![cfg(test)] + +use crate::service::{Error, UserPreferenceService}; +use actix_http::header; +use actix_web::{http::StatusCode, test as actix, App}; +use serde_json::json; +use test_context::test_context; +use test_log::test; +use trustify_common::model::Revisioned; +use trustify_test_context::auth::TestAuthentication; +use trustify_test_context::TrustifyContext; +use utoipa_actix_web::AppExt; + +#[test_context(TrustifyContext, skip_teardown)] +#[test(tokio::test)] +async fn collision(ctx: TrustifyContext) -> anyhow::Result<()> { + let service = UserPreferenceService::new(ctx.db.clone()); + + // initially it must be gone + + let result = service.get("user-a".into(), "key-a".into()).await?; + assert!(result.is_none()); + + // setting one with an invalid revision should rais a mid air collision + + let result = service + .set("user-a".into(), "key-a".into(), Some("a"), json!({"a": 1})) + .await; + assert!(matches!(result, Result::Err(Error::MidAirCollision))); + + // now set a proper one + + service + .set("user-a".into(), "key-a".into(), None, json!({"a": 1})) + .await?; + + // we should be able to get it + + let result = service.get("user-a".into(), "key-a".into()).await?; + assert!(matches!( + result, + Some(Revisioned { + value: serde_json::Value::Object(data), + revision: _ + }) if data["a"] == 1 + )); + + // try setting one again with an invalid revision + + let result = service + .set("user-a".into(), "key-a".into(), Some("a"), json!({"a": 1})) + .await; + assert!(matches!(result, Result::Err(Error::MidAirCollision))); + + // must not change the data + + let result = service.get("user-a".into(), "key-a".into()).await?; + assert!(matches!( + result, + Some(Revisioned { + value: serde_json::Value::Object(data), + revision: _ + }) if data["a"] == 1 + )); + + // now let's update the data + + service + .set("user-a".into(), "key-a".into(), None, json!({"a": 2})) + .await?; + + // it should change + + let result = service.get("user-a".into(), "key-a".into()).await?.unwrap(); + assert!(matches!( + result, + Revisioned { + value: serde_json::Value::Object(data), + revision: _ + } if data["a"] == 2 + )); + + // now let's update the data with a proper revision + + service + .set( + "user-a".into(), + "key-a".into(), + Some(&result.revision), + json!({"a": 3}), + ) + .await?; + + // check result, must change + + let result = service.get("user-a".into(), "key-a".into()).await?.unwrap(); + let Revisioned { value, revision } = result; + assert!(matches!( + value, + serde_json::Value::Object(data) if data["a"] == 3 + )); + + // try deleting wrong revision, must fail + + let result = service + .delete("user-a".into(), "key-a".into(), Some("a")) + .await; + assert!(matches!(result, Result::Err(Error::MidAirCollision))); + + // try deleting correct revision, must succeed + + let result = service + .delete("user-a".into(), "key-a".into(), Some(&revision)) + .await; + assert!(matches!(result, Result::Ok(true))); + + // try deleting correct revision again, must succeed, but return false + + let result = service + .delete("user-a".into(), "key-a".into(), Some(&revision)) + .await; + assert!(matches!(result, Result::Ok(false))); + + // try deleting any revision, must succeed, but return false + + let result = service.delete("user-a".into(), "key-a".into(), None).await; + assert!(matches!(result, Result::Ok(false))); + + Ok(()) +} + +#[test_context(TrustifyContext, skip_teardown)] +#[test(actix_web::test)] +async fn wrong_rev(ctx: TrustifyContext) { + let db = ctx.db; + let app = actix::init_service( + App::new() + .into_utoipa_app() + .service( + utoipa_actix_web::scope("/api") + .configure(|svc| super::endpoints::configure(svc, db)), + ) + .into_app(), + ) + .await; + + // create one + + let req = actix::TestRequest::put() + .uri("/api/v1/userPreference/foo") + .set_json(json!({"a": 1})) + .to_request() + .test_auth("user-a"); + + let resp = actix::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // try to update the wrong one + + let req = actix::TestRequest::put() + .uri("/api/v1/userPreference/foo") + .append_header((header::IF_MATCH, r#""a""#)) + .set_json(json!({"a": 2})) + .to_request() + .test_auth("user-a"); + + let resp = actix::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED); +} diff --git a/openapi.yaml b/openapi.yaml index c9f46c56..fe3416e4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1798,6 +1798,97 @@ paths: format: binary '404': description: The document could not be found + /api/v1/userPreference/{key}: + get: + tags: + - userPreferences + summary: Get user preferences + operationId: getUserPreferences + parameters: + - name: key + in: path + description: The key to the user preferences + required: true + schema: + type: string + responses: + '200': + description: User preference stored under this key + headers: + etag: + schema: + type: string + description: Revision ID + content: + application/json: + schema: {} + '404': + description: Unknown user preference key + put: + tags: + - userPreferences + summary: Set user preferences + operationId: setUserPreferences + parameters: + - name: key + in: path + description: The key to the user preferences + required: true + schema: + type: string + - name: if-match + in: header + description: The revision to update + required: false + schema: + type: + - string + - 'null' + requestBody: + content: + application/json: + schema: {} + required: true + responses: + '200': + description: User preference stored under this key + headers: + etag: + schema: + type: string + description: Revision ID + '412': + description: The provided If-Match revision did not match the actual revision + delete: + tags: + - userPreferences + summary: Delete user preferences + operationId: deleteUserPreferences + parameters: + - name: key + in: path + description: The key to the user preferences + required: true + schema: + type: string + - name: if-match + in: header + description: The revision to delete + required: false + schema: + type: + - string + - 'null' + requestBody: + content: + application/json: + schema: {} + required: true + responses: + '201': + description: User preferences are deleted + '412': + description: The provided If-Match revision did not match the actual revision /api/v1/vulnerability: get: tags: diff --git a/server/Cargo.toml b/server/Cargo.toml index 3d15e496..ebf84027 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,13 +9,14 @@ license.workspace = true trustify-auth = { workspace = true } trustify-common = { workspace = true } trustify-infrastructure = { workspace = true } +trustify-module-analysis = { workspace = true } trustify-module-fundamental = { workspace = true } +trustify-module-graphql = { workspace = true } trustify-module-importer = { workspace = true } trustify-module-ingestor = { workspace = true } trustify-module-storage = { workspace = true } trustify-module-ui = { workspace = true } -trustify-module-graphql = { workspace = true } -trustify-module-analysis = { workspace = true } +trustify-module-user = { workspace = true } actix-web = { workspace = true } anyhow = { workspace = true } diff --git a/server/src/lib.rs b/server/src/lib.rs index 13bbd8dd..c04f800d 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -448,6 +448,7 @@ fn configure(svc: &mut utoipa_actix_web::service_config::ServiceConfig, config: storage, ); trustify_module_analysis::endpoints::configure(svc, db.clone()); + trustify_module_user::endpoints::configure(svc, db.clone()); }), ); } diff --git a/test-context/src/auth.rs b/test-context/src/auth.rs new file mode 100644 index 00000000..86e123f3 --- /dev/null +++ b/test-context/src/auth.rs @@ -0,0 +1,30 @@ +use actix_http::{HttpMessage, Request}; +use trustify_auth::authenticator::user::{UserDetails, UserInformation}; + +/// Convenient way of adding (authenticated) user information to the request. +pub trait TestAuthentication: Sized { + /// Make the request an authenticated request with the provided user details + fn test_auth_details(self, details: UserDetails) -> Self; + + /// Make the request an authenticated request with the provided user id + fn test_auth(self, id: impl Into) -> Self { + self.test_auth_details(UserDetails { + id: id.into(), + permissions: vec![], + }) + } +} + +impl TestAuthentication for Request { + fn test_auth_details(self, details: UserDetails) -> Self { + test_auth(self, details) + } +} + +/// Add data making the request authenticated. +pub fn test_auth(request: Request, details: UserDetails) -> Request { + request + .extensions_mut() + .insert(UserInformation::Authenticated(details)); + request +} diff --git a/test-context/src/lib.rs b/test-context/src/lib.rs index b081a5de..27680a58 100644 --- a/test-context/src/lib.rs +++ b/test-context/src/lib.rs @@ -1,5 +1,6 @@ #![allow(clippy::expect_used)] +pub mod auth; pub mod call; pub mod spdx;