From e594e60f758eb8d9de55fac9d1ef5d666262ce03 Mon Sep 17 00:00:00 2001 From: Dominik Winecki Date: Thu, 16 Jan 2025 17:42:58 +0000 Subject: [PATCH] Add user permission system --- scripts/igni-test.py | 20 +- vidformer-igni/README.md | 2 +- vidformer-igni/init/setup.sql | 3 +- vidformer-igni/src/main.rs | 12 +- vidformer-igni/src/ops.rs | 9 +- vidformer-igni/src/schema.rs | 1 + vidformer-igni/src/server.rs | 256 ++++++++++++++++++++++++- vidformer-igni/src/server/api.rs | 48 ++++- vidformer-py/vidformer/igni/vf_igni.py | 1 - 9 files changed, 334 insertions(+), 18 deletions(-) diff --git a/scripts/igni-test.py b/scripts/igni-test.py index c0c8388..5f75161 100755 --- a/scripts/igni-test.py +++ b/scripts/igni-test.py @@ -39,7 +39,17 @@ # Add a user for the tests test_user = sp.run( - [vidformer_igni_bin, "user", "add", "--name", "test", "--api-key", "test"], + [ + vidformer_igni_bin, + "user", + "add", + "--name", + "test", + "--api-key", + "test", + "--permissions", + "full", + ], capture_output=True, check=True, env=igni_env, @@ -106,13 +116,7 @@ ) tmp_user = sp.run( - [ - vidformer_igni_bin, - "user", - "add", - "--name", - "tmp_user", - ], + [vidformer_igni_bin, "user", "add", "--name", "tmp_user", "--permissions", "full"], check=True, capture_output=True, env=igni_env, diff --git a/vidformer-igni/README.md b/vidformer-igni/README.md index 28dced5..0fbe766 100644 --- a/vidformer-igni/README.md +++ b/vidformer-igni/README.md @@ -7,7 +7,7 @@ The next generation scale-out vidformer server. ```bash docker-compose -f docker-compose-db.yaml up export 'IGNI_DB=postgres://igni:igni@localhost:5432/igni' -cargo run -- user add --name test --api-key test +cargo run -- user add --name test --api-key test --permissions full cargo run -- server --config igni.toml ``` diff --git a/vidformer-igni/init/setup.sql b/vidformer-igni/init/setup.sql index 81953fd..35c67fc 100644 --- a/vidformer-igni/init/setup.sql +++ b/vidformer-igni/init/setup.sql @@ -4,7 +4,8 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE TABLE "user" ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name TEXT NOT NULL UNIQUE, -- Not actually used for authentication, just for record keeping - api_key VARCHAR(32) NOT NULL UNIQUE + api_key VARCHAR(32) NOT NULL UNIQUE, + permissions JSONB NOT NULL ); CREATE INDEX user_api_key_idx ON "user"(api_key); diff --git a/vidformer-igni/src/main.rs b/vidformer-igni/src/main.rs index cadc0fd..fdaa531 100644 --- a/vidformer-igni/src/main.rs +++ b/vidformer-igni/src/main.rs @@ -122,12 +122,19 @@ enum UserCmd { Rm(UserRmOpt), } +#[derive(clap::ValueEnum, Debug, Clone)] +enum UserPermissionLevel { + Full, +} + #[derive(Parser, Debug)] struct UserAddOpt { #[clap(long)] name: String, #[clap(long)] api_key: Option, + #[clap(long)] + permissions: UserPermissionLevel, } #[derive(Parser, Debug)] @@ -511,7 +518,10 @@ async fn cmd_user_add( .map(char::from) .collect(), }; - let user_id = ops::add_user(&pool, &name, &api_key).await?; + let permissions = match add_user.permissions { + UserPermissionLevel::Full => server::UserPermissions::default_full(), + }; + let user_id = ops::add_user(&pool, &name, &api_key, &permissions).await?; println!("{}", user_id); if add_user.api_key.is_none() { println!("{}", api_key); diff --git a/vidformer-igni/src/ops.rs b/vidformer-igni/src/ops.rs index 4d0fb14..f701840 100644 --- a/vidformer-igni/src/ops.rs +++ b/vidformer-igni/src/ops.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use crate::server; + use super::IgniError; pub(crate) async fn ping(pool: &sqlx::Pool) -> Result<(), IgniError> { @@ -54,15 +56,17 @@ pub(crate) async fn add_user( pool: &sqlx::Pool, name: &str, api_key: &str, + permissions: &server::UserPermissions, ) -> Result { let user_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO \"user\" (id, name, api_key) VALUES ($1, $2, $3)") + let permissions = permissions.json_value(); + sqlx::query("INSERT INTO \"user\" (id, name, api_key, permissions) VALUES ($1, $2, $3, $4)") .bind(user_id) .bind(name) .bind(api_key) + .bind(permissions) .execute(pool) .await?; - Ok(user_id) } @@ -90,6 +94,7 @@ pub(crate) async fn profile_and_add_source( }) .await .expect("Failed joining blocking thread")?; + let mut transaction = pool.begin().await?; let source_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO source (id, user_id, name, stream_idx, storage_service, storage_config, codec, pix_fmt, width, height, file_size) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)") diff --git a/vidformer-igni/src/schema.rs b/vidformer-igni/src/schema.rs index ff6c350..734d374 100644 --- a/vidformer-igni/src/schema.rs +++ b/vidformer-igni/src/schema.rs @@ -3,6 +3,7 @@ pub struct UserRow { pub id: uuid::Uuid, pub name: String, pub api_key: String, + pub permissions: serde_json::Value, } #[derive(sqlx::FromRow, Debug)] diff --git a/vidformer-igni/src/server.rs b/vidformer-igni/src/server.rs index bbb0198..b8b4492 100644 --- a/vidformer-igni/src/server.rs +++ b/vidformer-igni/src/server.rs @@ -3,6 +3,7 @@ use crate::schema; use super::IgniError; use super::ServerOpt; use log::*; +use num_rational::Rational64; use regex::Regex; mod api; @@ -15,6 +16,216 @@ struct IgniServerGlobal { struct UserAuth { user_id: uuid::Uuid, + permissions: UserPermissions, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct UserPermissions { + flags: Vec, + valsets: std::collections::BTreeMap>, + limits_int: std::collections::BTreeMap, + limits_frac: std::collections::BTreeMap, +} + +impl UserPermissions { + pub fn json_value(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap() + } +} + +impl UserPermissions { + pub fn default_full() -> UserPermissions { + let limits_int = [ + ("spec:max_width", 4096), // DCI 4K + ("spec:max_height", 2160), // DCI 4K + ] + .iter() + .map(|(key, value)| (key.to_string(), *value)) + .collect(); + + let limits_frac = [ + ("spec:max_vod_segment_length", (3, 1)), + ("spec:min_vod_segment_legth", (1, 1)), + ("spec:max_frame_rate", (60, 1)), + ("spec:min_frame_rate", (1, 1)), + ] + .iter() + .map(|(key, value)| (key.to_string(), Rational64::new(value.0, value.1))) + .collect(); + + let valsets = [("spec:pix_fmt", ["yuv420p"])] + .iter() + .map(|(key, values)| { + let values = values.iter().map(|v| v.to_string()).collect(); + (key.to_string(), values) + }) + .collect(); + + let flags = vec![ + // Source permissions + "source:create", + "source:get", + "source:list", + "source:search", + "source:delete", + // Spec permissions + "spec:create", + "spec:get", + "spec:list", + "spec:push_part", + "spec:delete", + ] + .into_iter() + .map(String::from) + .collect(); + + UserPermissions { + flags, + valsets, + limits_int, + limits_frac, + } + } + + pub fn flag(&self, flag: &str) -> bool { + self.flags.contains(&flag.to_string()) + } + + pub fn flag_err( + &self, + flag: &str, + ) -> Option>> { + if !self.flag(flag) { + let mut res = hyper::Response::new(http_body_util::Full::new( + hyper::body::Bytes::from(format!("Permission denied - {}", flag)), + )); + *res.status_mut() = hyper::StatusCode::FORBIDDEN; + Some(res) + } else { + None + } + } + + pub fn limit(&self, limit: &str) -> Option { + self.limits_int.get(limit).cloned() + } + + pub fn limit_err_max( + &self, + limit: &str, + value: i64, + ) -> Option>> { + if let Some(limit_value) = self.limit(limit) { + if value > limit_value { + let mut res = + hyper::Response::new(http_body_util::Full::new(hyper::body::Bytes::from( + format!("Limit {} exceeded - {} > {}", limit, value, limit_value), + ))); + *res.status_mut() = hyper::StatusCode::FORBIDDEN; + Some(res) + } else { + None + } + } else { + None + } + } + + pub fn limit_err_min( + &self, + limit: &str, + value: i64, + ) -> Option>> { + if let Some(limit_value) = self.limit(limit) { + if value < limit_value { + let mut res = + hyper::Response::new(http_body_util::Full::new(hyper::body::Bytes::from( + format!("Limit {} exceeded - {} < {}", limit, value, limit_value), + ))); + *res.status_mut() = hyper::StatusCode::FORBIDDEN; + Some(res) + } else { + None + } + } else { + None + } + } + + pub fn limit_frac(&self, limit: &str) -> Option { + self.limits_frac.get(limit).cloned() + } + + pub fn limit_frac_err_max( + &self, + limit: &str, + value: Rational64, + ) -> Option>> { + if let Some(limit_value) = self.limit_frac(limit) { + if value > limit_value { + let mut res = + hyper::Response::new(http_body_util::Full::new(hyper::body::Bytes::from( + format!("Limit {} exceeded - {} > {}", limit, value, limit_value), + ))); + *res.status_mut() = hyper::StatusCode::FORBIDDEN; + Some(res) + } else { + None + } + } else { + None + } + } + + pub fn limit_frac_err_min( + &self, + limit: &str, + value: Rational64, + ) -> Option>> { + if let Some(limit_value) = self.limit_frac(limit) { + if value < limit_value { + let mut res = + hyper::Response::new(http_body_util::Full::new(hyper::body::Bytes::from( + format!("Limit {} exceeded - {} < {}", limit, value, limit_value), + ))); + *res.status_mut() = hyper::StatusCode::FORBIDDEN; + Some(res) + } else { + None + } + } else { + None + } + } + + pub fn valset_err( + &self, + valset: &str, + value: &str, + ) -> Option>> { + if let Some(allowed_values) = self.valsets.get(valset) { + if !allowed_values.contains(value) { + let mut res = hyper::Response::new(http_body_util::Full::new( + hyper::body::Bytes::from(format!( + "Invalid value for {} - {} not in {{{}}}", + valset, + value, + allowed_values + .iter() + .cloned() + .collect::>() + .join(", ") + )), + )); + *res.status_mut() = hyper::StatusCode::FORBIDDEN; + Some(res) + } else { + None + } + } else { + None + } + } } fn load_config(path: &String) -> Result { @@ -241,8 +452,17 @@ async fn igni_http_req_api( return Ok(res); } }; - - let user_auth = UserAuth { user_id: user.id }; + let user_permissions: UserPermissions = + serde_json::from_value(user.permissions).map_err(|e| { + IgniError::General(format!( + "Failed to parse user permissions for user {}: {}", + user.id, e + )) + })?; + let user_auth = UserAuth { + user_id: user.id, + permissions: user_permissions, + }; match (method, uri.as_str()) { (hyper::Method::GET, "/v2/auth") // /v2/auth (for checking auth success) @@ -251,6 +471,9 @@ async fn igni_http_req_api( } (hyper::Method::GET, "/v2/source") // /v2/source (list) => { + if let Some(res) = user_auth.permissions.flag_err("source:list") { + return Ok(res); + } api::list_sources(req, global, &user_auth).await } (hyper::Method::GET, _) // /v2/source/ @@ -258,6 +481,9 @@ async fn igni_http_req_api( Regex::new(r"^/v2/source/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$").unwrap().is_match(req.uri().path()) } => { + if let Some(res) = user_auth.permissions.flag_err("source:get") { + return Ok(res); + } let r = Regex::new( r"^/v2/source/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", ); @@ -266,17 +492,26 @@ async fn igni_http_req_api( api::get_source(req, global, source_id, &user_auth).await } (hyper::Method::POST, "/v2/source/search") => { + if let Some(res) = user_auth.permissions.flag_err("source:search") { + return Ok(res); + } api::search_source(req, global, &user_auth).await } (hyper::Method::POST, "/v2/source") // /v2/source => { - api::push_source(req, global, &user_auth).await + if let Some(res) = user_auth.permissions.flag_err("source:create") { + return Ok(res); + } + api::create_source(req, global, &user_auth).await } (hyper::Method::DELETE, _) // /v2/source/ if { Regex::new(r"^/v2/source/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$").unwrap().is_match(req.uri().path()) } => { + if let Some(res) = user_auth.permissions.flag_err("source:delete") { + return Ok(res); + } let r = Regex::new( r"^/v2/source/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", ); @@ -286,6 +521,9 @@ async fn igni_http_req_api( } (hyper::Method::GET, "/v2/spec") // /v2/spec (list) => { + if let Some(res) = user_auth.permissions.flag_err("spec:list") { + return Ok(res); + } api::list_specs(req, global, &user_auth).await } (hyper::Method::GET, _) // /v2/spec/ @@ -293,6 +531,9 @@ async fn igni_http_req_api( Regex::new(r"^/v2/spec/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$").unwrap().is_match(req.uri().path()) } => { + if let Some(res) = user_auth.permissions.flag_err("spec:get") { + return Ok(res); + } let r = Regex::new( r"^/v2/spec/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", ); @@ -305,6 +546,9 @@ async fn igni_http_req_api( Regex::new(r"^/v2/spec/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$").unwrap().is_match(req.uri().path()) } => { + if let Some(res) = user_auth.permissions.flag_err("spec:delete") { + return Ok(res); + } let r = Regex::new( r"^/v2/spec/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", ); @@ -314,6 +558,9 @@ async fn igni_http_req_api( } (hyper::Method::POST, "/v2/spec") // /v2/spec => { + if let Some(res) = user_auth.permissions.flag_err("spec:create") { + return Ok(res); + } api::push_spec(req, global, &user_auth).await } (hyper::Method::POST, _) // /v2/spec//part @@ -321,6 +568,9 @@ async fn igni_http_req_api( Regex::new(r"^/v2/spec/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/part$").unwrap().is_match(req.uri().path()) } => { + if let Some(res) = user_auth.permissions.flag_err("spec:push_part") { + return Ok(res); + } let r = Regex::new( r"^/v2/spec/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/part$", ); diff --git a/vidformer-igni/src/server/api.rs b/vidformer-igni/src/server/api.rs index 979f80c..049320e 100644 --- a/vidformer-igni/src/server/api.rs +++ b/vidformer-igni/src/server/api.rs @@ -4,6 +4,7 @@ use super::super::IgniError; use crate::schema; use http_body_util::BodyExt; use log::*; +use num_rational::Rational64; use uuid::Uuid; use super::IgniServerGlobal; @@ -234,7 +235,7 @@ pub(crate) async fn delete_source( )))?) } -pub(crate) async fn push_source( +pub(crate) async fn create_source( req: hyper::Request, global: std::sync::Arc, user: &super::UserAuth, @@ -469,6 +470,51 @@ pub(crate) async fn push_spec( Ok(req) => req, }; + if let Some(err) = user + .permissions + .limit_err_max("spec:max_width", req.width as i64) + { + return Ok(err); + } + if let Some(err) = user + .permissions + .limit_err_max("spec:max_height", req.height as i64) + { + return Ok(err); + } + if let Some(err) = user.permissions.valset_err("spec:pix_fmt", &req.pix_fmt) { + return Ok(err); + }; + let vod_segment_length: Rational64 = Rational64::new( + req.vod_segment_length[0] as i64, + req.vod_segment_length[1] as i64, + ); + if let Some(err) = user + .permissions + .limit_frac_err_max("spec:max_vod_segment_length", vod_segment_length) + { + return Ok(err); + } + if let Some(err) = user + .permissions + .limit_frac_err_min("spec:min_vod_segment_length", vod_segment_length) + { + return Ok(err); + } + let frame_rate = Rational64::new(req.frame_rate[0] as i64, req.frame_rate[1] as i64); + if let Some(err) = user + .permissions + .limit_frac_err_max("spec:max_frame_rate", frame_rate) + { + return Ok(err); + } + if let Some(err) = user + .permissions + .limit_frac_err_min("spec:min_frame_rate", frame_rate) + { + return Ok(err); + } + let spec = crate::ops::add_spec( &global.pool, &user.user_id, diff --git a/vidformer-py/vidformer/igni/vf_igni.py b/vidformer-py/vidformer/igni/vf_igni.py index 43f9296..9907af3 100644 --- a/vidformer-py/vidformer/igni/vf_igni.py +++ b/vidformer-py/vidformer/igni/vf_igni.py @@ -1,7 +1,6 @@ from .. import vf import requests -import json from fractions import Fraction from urllib.parse import urlparse