diff --git a/apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql b/apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql new file mode 100644 index 00000000..003fe273 --- /dev/null +++ b/apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS personal_access_tokens (id VARCHAR(36) PRIMARY KEY,user_id VARCHAR(36) NOT NULL,name VARCHAR(255) NOT NULL,token_hash TEXT NOT NULL,token_hmac VARCHAR(128) NOT NULL,token_prefix VARCHAR(32) NOT NULL,scopes_json TEXT NOT NULL,created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,last_used_at TIMESTAMP NULL,expires_at TIMESTAMP NULL,revoked_at TIMESTAMP NULL,FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE); +CREATE INDEX idx_pat_prefix ON personal_access_tokens(token_prefix); +ALTER TABLE access_tokens ADD COLUMN scopes_json TEXT NOT NULL DEFAULT ('[]'); +CREATE TABLE IF NOT EXISTS storage_profiles (id VARCHAR(36) PRIMARY KEY,name VARCHAR(255) NOT NULL UNIQUE,kind VARCHAR(20) NOT NULL,endpoint TEXT,region VARCHAR(255),bucket VARCHAR(255),public_url TEXT,credentials_encrypted TEXT,enabled BOOLEAN NOT NULL DEFAULT TRUE,immutable BOOLEAN NOT NULL DEFAULT FALSE,created_by VARCHAR(36),created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY(created_by) REFERENCES users(id)); +INSERT IGNORE INTO storage_profiles(id,name,kind,enabled,immutable) VALUES ('local-filesystem','Local Filesystem','filesystem',TRUE,TRUE); +ALTER TABLE sites ADD COLUMN storage_profile_id VARCHAR(36) NULL; +UPDATE sites SET storage_profile_id='local-filesystem' WHERE storage_profile_id IS NULL; +ALTER TABLE backups ADD COLUMN storage_profile_id VARCHAR(36) NULL; +ALTER TABLE backup_schedules ADD COLUMN storage_profile_id VARCHAR(36) NULL; +ALTER TABLE backups ADD CONSTRAINT fk_backups_storage_profile FOREIGN KEY (storage_profile_id) REFERENCES storage_profiles(id); +ALTER TABLE backup_schedules ADD CONSTRAINT fk_backup_schedules_storage_profile FOREIGN KEY (storage_profile_id) REFERENCES storage_profiles(id); +CREATE TABLE IF NOT EXISTS deployment_triggers (id VARCHAR(36) PRIMARY KEY,site_id VARCHAR(36) NOT NULL,label VARCHAR(255) NOT NULL,provider VARCHAR(32) NOT NULL,url_encrypted TEXT NOT NULL,headers_encrypted TEXT NOT NULL,enabled BOOLEAN NOT NULL DEFAULT TRUE,is_primary BOOLEAN NOT NULL DEFAULT FALSE,cooldown_seconds BIGINT NOT NULL DEFAULT 60,daily_quota BIGINT NOT NULL DEFAULT 20,created_by VARCHAR(36),created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE,FOREIGN KEY(created_by) REFERENCES users(id)); +CREATE TABLE IF NOT EXISTS deployment_jobs (id VARCHAR(36) PRIMARY KEY,trigger_id VARCHAR(36) NOT NULL,site_id VARCHAR(36) NOT NULL,status VARCHAR(32) NOT NULL,status_code INTEGER,error_category VARCHAR(32),response_body TEXT,retry_after_seconds BIGINT,duration_ms BIGINT,triggered_by VARCHAR(36),created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,started_at TIMESTAMP NULL,finished_at TIMESTAMP NULL,FOREIGN KEY(trigger_id) REFERENCES deployment_triggers(id) ON DELETE CASCADE,FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE); +CREATE INDEX idx_deployment_jobs_trigger ON deployment_jobs(trigger_id,created_at); +ALTER TABLE deployment_jobs ADD COLUMN active_trigger_id VARCHAR(36) GENERATED ALWAYS AS (CASE WHEN status IN ('queued','running') THEN trigger_id ELSE NULL END) STORED; +CREATE UNIQUE INDEX idx_deployment_jobs_active_trigger ON deployment_jobs(active_trigger_id); diff --git a/apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql b/apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql new file mode 100644 index 00000000..aa6e071e --- /dev/null +++ b/apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS personal_access_tokens (id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,name TEXT NOT NULL,token_hash TEXT NOT NULL,token_hmac TEXT NOT NULL,token_prefix TEXT NOT NULL,scopes_json TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),last_used_at TIMESTAMPTZ,expires_at TIMESTAMPTZ,revoked_at TIMESTAMPTZ); +CREATE INDEX IF NOT EXISTS idx_pat_prefix ON personal_access_tokens(token_prefix); +ALTER TABLE access_tokens ADD COLUMN IF NOT EXISTS scopes_json TEXT NOT NULL DEFAULT '[]'; +CREATE TABLE IF NOT EXISTS storage_profiles (id TEXT PRIMARY KEY,name TEXT NOT NULL UNIQUE,kind TEXT NOT NULL CHECK(kind IN ('filesystem','s3')),endpoint TEXT,region TEXT,bucket TEXT,public_url TEXT,credentials_encrypted TEXT,enabled BOOLEAN NOT NULL DEFAULT TRUE,immutable BOOLEAN NOT NULL DEFAULT FALSE,created_by TEXT REFERENCES users(id),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); +INSERT INTO storage_profiles(id,name,kind,enabled,immutable) VALUES ('local-filesystem','Local Filesystem','filesystem',TRUE,TRUE) ON CONFLICT DO NOTHING; +ALTER TABLE sites ADD COLUMN IF NOT EXISTS storage_profile_id TEXT REFERENCES storage_profiles(id); +UPDATE sites SET storage_profile_id='local-filesystem' WHERE storage_profile_id IS NULL; +ALTER TABLE backups ADD COLUMN IF NOT EXISTS storage_profile_id TEXT REFERENCES storage_profiles(id); +ALTER TABLE backup_schedules ADD COLUMN IF NOT EXISTS storage_profile_id TEXT REFERENCES storage_profiles(id); +CREATE TABLE IF NOT EXISTS deployment_triggers (id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,label TEXT NOT NULL,provider TEXT NOT NULL,url_encrypted TEXT NOT NULL,headers_encrypted TEXT NOT NULL,enabled BOOLEAN NOT NULL DEFAULT TRUE,is_primary BOOLEAN NOT NULL DEFAULT FALSE,cooldown_seconds BIGINT NOT NULL DEFAULT 60,daily_quota BIGINT NOT NULL DEFAULT 20,created_by TEXT REFERENCES users(id),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); +CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_primary ON deployment_triggers(site_id) WHERE is_primary=TRUE; +CREATE TABLE IF NOT EXISTS deployment_jobs (id TEXT PRIMARY KEY,trigger_id TEXT NOT NULL REFERENCES deployment_triggers(id) ON DELETE CASCADE,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,status TEXT NOT NULL,status_code INTEGER,error_category TEXT,response_body TEXT,retry_after_seconds BIGINT,duration_ms BIGINT,triggered_by TEXT,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),started_at TIMESTAMPTZ,finished_at TIMESTAMPTZ); +CREATE INDEX IF NOT EXISTS idx_deployment_jobs_trigger ON deployment_jobs(trigger_id,created_at DESC); +CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_jobs_active_trigger ON deployment_jobs(trigger_id) WHERE status IN ('queued','running'); diff --git a/apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql b/apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql new file mode 100644 index 00000000..abd818a1 --- /dev/null +++ b/apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS personal_access_tokens ( + id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, token_hash TEXT NOT NULL, token_hmac TEXT NOT NULL, + token_prefix TEXT NOT NULL, scopes_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used_at TEXT, + expires_at TEXT, revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_pat_prefix ON personal_access_tokens(token_prefix); +ALTER TABLE access_tokens ADD COLUMN scopes_json TEXT NOT NULL DEFAULT '[]'; + +CREATE TABLE IF NOT EXISTS storage_profiles ( + id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, kind TEXT NOT NULL CHECK(kind IN ('filesystem','s3')), + endpoint TEXT, region TEXT, bucket TEXT, public_url TEXT, credentials_encrypted TEXT, + enabled INTEGER NOT NULL DEFAULT 1, immutable INTEGER NOT NULL DEFAULT 0, + created_by TEXT REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT OR IGNORE INTO storage_profiles(id,name,kind,enabled,immutable) VALUES ('local-filesystem','Local Filesystem','filesystem',1,1); +ALTER TABLE sites ADD COLUMN storage_profile_id TEXT REFERENCES storage_profiles(id); +UPDATE sites SET storage_profile_id = 'local-filesystem' WHERE storage_profile_id IS NULL; +ALTER TABLE backups ADD COLUMN storage_profile_id TEXT REFERENCES storage_profiles(id); +ALTER TABLE backup_schedules ADD COLUMN storage_profile_id TEXT REFERENCES storage_profiles(id); + +CREATE TABLE IF NOT EXISTS deployment_triggers ( + id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + label TEXT NOT NULL, provider TEXT NOT NULL, url_encrypted TEXT NOT NULL, headers_encrypted TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, is_primary INTEGER NOT NULL DEFAULT 0, + cooldown_seconds INTEGER NOT NULL DEFAULT 60, daily_quota INTEGER NOT NULL DEFAULT 20, + created_by TEXT REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_primary ON deployment_triggers(site_id) WHERE is_primary = 1; +CREATE TABLE IF NOT EXISTS deployment_jobs ( + id TEXT PRIMARY KEY, trigger_id TEXT NOT NULL REFERENCES deployment_triggers(id) ON DELETE CASCADE, + site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, status TEXT NOT NULL, + status_code INTEGER, error_category TEXT, response_body TEXT, retry_after_seconds INTEGER, + duration_ms INTEGER, triggered_by TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, finished_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_deployment_jobs_trigger ON deployment_jobs(trigger_id, created_at DESC); +CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_jobs_active_trigger + ON deployment_jobs(trigger_id) WHERE status IN ('queued', 'running'); diff --git a/apps/backend/src/graphql/context.rs b/apps/backend/src/graphql/context.rs index 87e9907f..02e9e79c 100644 --- a/apps/backend/src/graphql/context.rs +++ b/apps/backend/src/graphql/context.rs @@ -1,14 +1,16 @@ use crate::middleware::auth::{Actor, verify_access_token}; -use crate::models::access_token::AccessTokenPermission; +use crate::models::access_token::{TokenScope, TokenScopes}; +use crate::models::authorization::Action; use crate::repository::Repository; use crate::services::Services; +use crate::services::authorization::AuthorizationService; pub struct GqlContext { pub repository: Repository, pub services: Services, pub actor: Option, pub site_id: Option, - pub permission: Option, + pub permission: Option, } impl GqlContext { @@ -16,6 +18,7 @@ impl GqlContext { repository: Repository, services: Services, auth_header: Option<&str>, + requested_site: Option<&str>, hmac_secret: &str, ) -> Self { let mut actor = None; @@ -24,12 +27,22 @@ impl GqlContext { if let Some(header) = auth_header && let Some(token) = header.strip_prefix("Bearer ") - && token.starts_with("vcms_site_") + && (token.starts_with("vcms_site_") || token.starts_with("vcms_pat_")) && let Ok(auth_actor) = verify_access_token(token, &repository, hmac_secret).await { if let Actor::ApiKey(k) = &auth_actor { site_id = Some(k.site_id.clone()); - permission = Some(k.permission.clone()); + permission = Some(k.scopes.clone()); + } else if let Actor::PersonalToken(k) = &auth_actor { + permission = Some(k.scopes.clone()); + if let Some(requested_site) = requested_site + && AuthorizationService::new(repository.user.clone()) + .require_site_action(&auth_actor, requested_site, Action::SiteRead) + .await + .is_ok() + { + site_id = Some(requested_site.to_owned()); + } } actor = Some(auth_actor); } @@ -51,8 +64,12 @@ impl GqlContext { pub fn require_read(&self) -> async_graphql::Result<()> { match (&self.actor, &self.permission) { - (Some(Actor::ApiKey(_)), Some(_)) => Ok(()), - (Some(Actor::ApiKey(_)), None) => Err(async_graphql::Error::new( + (Some(Actor::ApiKey(_) | Actor::PersonalToken(_)), Some(scopes)) + if scopes.contains(&TokenScope::ContentRead) => + { + Ok(()) + } + (Some(Actor::ApiKey(_) | Actor::PersonalToken(_)), None) => Err(async_graphql::Error::new( "Access token does not have required permission", )), _ => Err(async_graphql::Error::new("Site token authentication required")), @@ -61,10 +78,8 @@ impl GqlContext { pub fn require_write(&self) -> async_graphql::Result<()> { match &self.permission { - Some(AccessTokenPermission::Write) => Ok(()), - Some(AccessTokenPermission::Read) => { - Err(async_graphql::Error::new("Access token does not have write permission")) - } + Some(scopes) if scopes.contains(&TokenScope::ContentWrite) => Ok(()), + Some(_) => Err(async_graphql::Error::new("Access token does not have write permission")), None => Err(async_graphql::Error::new("Site token authentication required")), } } diff --git a/apps/backend/src/handlers/access_token_handler.rs b/apps/backend/src/handlers/access_token_handler.rs index 07f4da58..4878195a 100644 --- a/apps/backend/src/handlers/access_token_handler.rs +++ b/apps/backend/src/handlers/access_token_handler.rs @@ -7,8 +7,8 @@ use axum::{ use serde_json::json; use tracing::instrument; -use crate::middleware::auth::{RequestContext, require_user_action}; -use crate::models::access_token::CreateSiteToken; +use crate::middleware::auth::{Actor, AuthContext, RequestContext, require_user_action}; +use crate::models::access_token::{CreatePersonalAccessToken, CreateSiteToken}; use crate::models::authorization::Action; use crate::repository::Repository; use crate::services::Services; @@ -50,7 +50,7 @@ pub async fn create_site_token( match services .access_token - .create_site_token(&site_id, payload.name, payload.permission, Some(user_id)) + .create_site_token(&site_id, payload.name, payload.scopes, Some(user_id)) .await { Ok(response) => (StatusCode::CREATED, Json(response)).into_response(), @@ -58,6 +58,81 @@ pub async fn create_site_token( } } +pub async fn list_personal_tokens(auth: AuthContext, Extension(services): Extension) -> Response { + let Actor::User(user) = auth.actor else { + return (StatusCode::FORBIDDEN, Json(json!({"error":"session_required"}))).into_response(); + }; + match services.access_token.list_personal_tokens(&user.user_id).await { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => e.into_response(), + } +} + +pub async fn create_personal_token( + auth: AuthContext, + Extension(repository): Extension, + Extension(services): Extension, + Json(payload): Json, +) -> Response { + let Actor::User(user) = auth.actor else { + return (StatusCode::FORBIDDEN, Json(json!({"error":"session_required"}))).into_response(); + }; + let operator = repository + .user + .find_by_id(&user.user_id) + .await + .ok() + .flatten() + .and_then(|account| account.instance_role) + .is_some(); + if !operator + && payload.scopes.iter().any(|scope| { + matches!( + scope, + crate::models::access_token::TokenScope::SiteSettingsWrite + | crate::models::access_token::TokenScope::SchemaWrite + | crate::models::access_token::TokenScope::WebhooksRead + | crate::models::access_token::TokenScope::WebhooksWrite + | crate::models::access_token::TokenScope::WebhooksTrigger + | crate::models::access_token::TokenScope::DeploymentsWrite + ) + }) + { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error":"scope_exceeds_current_role"})), + ) + .into_response(); + } + match services + .access_token + .create_personal_token(&user.user_id, payload) + .await + { + Ok(v) => (StatusCode::CREATED, Json(v)).into_response(), + Err(e) => e.into_response(), + } +} + +pub async fn revoke_personal_token( + auth: AuthContext, + Path(token_id): Path, + Extension(services): Extension, +) -> Response { + let Actor::User(user) = auth.actor else { + return (StatusCode::FORBIDDEN, Json(json!({"error":"session_required"}))).into_response(); + }; + match services + .access_token + .revoke_personal_token(&token_id, &user.user_id) + .await + { + Ok(0) => (StatusCode::NOT_FOUND, Json(json!({"error":"not_found"}))).into_response(), + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => e.into_response(), + } +} + #[instrument(skip(repository, services, ctx))] pub async fn delete_site_token( ctx: RequestContext, diff --git a/apps/backend/src/handlers/backup_handler.rs b/apps/backend/src/handlers/backup_handler.rs index 61c7de1c..74d3335a 100644 --- a/apps/backend/src/handlers/backup_handler.rs +++ b/apps/backend/src/handlers/backup_handler.rs @@ -38,6 +38,7 @@ pub struct CreateBackupBody { pub include_files: Option, #[serde(default)] pub encrypt: bool, + pub storage_profile_id: Option, } #[derive(Deserialize)] @@ -47,6 +48,7 @@ pub struct ScheduleBody { pub include_files: Option, pub encrypt: Option, pub enabled: Option, + pub storage_profile_id: Option, } #[derive(Deserialize)] @@ -127,6 +129,7 @@ async fn create_backup( scope, schedule_id: None, created_by, + storage_profile_id: body.storage_profile_id, }; match backup.create_backup(opts).await { Ok(row) => (StatusCode::CREATED, Json(meta::BackupInfo::from(row))).into_response(), @@ -284,6 +287,7 @@ async fn create_schedule( body.enabled.unwrap_or(true), Some(&next), created_by.as_deref(), + body.storage_profile_id.as_deref(), &now, ) .await; @@ -332,6 +336,7 @@ async fn update_schedule(backup: &BackupService, id: &str, expect_site: Option<& body.encrypt.unwrap_or(false), body.enabled.unwrap_or(true), next.as_deref(), + body.storage_profile_id.as_deref(), &now, ) .await; @@ -388,6 +393,7 @@ async fn run_schedule_now( encrypt: row.encrypt != 0, schedule_id: Some(row.id.clone()), created_by, + storage_profile_id: row.storage_profile_id, }; match backup.create_backup(opts).await { Ok(b) => (StatusCode::CREATED, Json(meta::BackupInfo::from(b))).into_response(), diff --git a/apps/backend/src/handlers/deployment_handler.rs b/apps/backend/src/handlers/deployment_handler.rs new file mode 100644 index 00000000..0fa5a9b0 --- /dev/null +++ b/apps/backend/src/handlers/deployment_handler.rs @@ -0,0 +1,111 @@ +use crate::{ + middleware::auth::{RequestContext, require_site_action}, + models::{authorization::Action, deployment::CreateDeploymentTrigger}, + repository::Repository, + services::Services, +}; +use axum::{ + Json, + extract::{Extension, Path}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; + +pub async fn list( + ctx: RequestContext, + Path(site_id): Path, + Extension(repo): Extension, + Extension(services): Extension, +) -> Response { + if let Err(v) = require_site_action(&ctx, &repo, Action::DeploymentsRead).await { + return v.into_response(); + } + match services.deployment.list(&site_id).await { + Ok(v) => Json(v).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":e}))).into_response(), + } +} +pub async fn create( + ctx: RequestContext, + Path(site_id): Path, + Extension(repo): Extension, + Extension(services): Extension, + Json(value): Json, +) -> Response { + if let Err(v) = require_site_action(&ctx, &repo, Action::DeploymentsWrite).await { + return v.into_response(); + } + let user = ctx.auth.actor.user_id().unwrap_or("system"); + match services.deployment.create(&site_id, user, value).await { + Ok(v) => (StatusCode::CREATED, Json(v)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error":e}))).into_response(), + } +} +pub async fn update( + ctx: RequestContext, + Path((site_id, trigger_id)): Path<(String, String)>, + Extension(repo): Extension, + Extension(services): Extension, + Json(value): Json, +) -> Response { + if let Err(response) = require_site_action(&ctx, &repo, Action::DeploymentsWrite).await { + return response.into_response(); + } + match services.deployment.update(&site_id, &trigger_id, value).await { + Ok(trigger) => Json(trigger).into_response(), + Err(error) if error == "trigger_not_found" => StatusCode::NOT_FOUND.into_response(), + Err(error) => (StatusCode::BAD_REQUEST, Json(json!({"error": error}))).into_response(), + } +} +pub async fn trigger( + ctx: RequestContext, + Path((site_id, trigger_id)): Path<(String, String)>, + Extension(repo): Extension, + Extension(services): Extension, +) -> Response { + if let Err(v) = require_site_action(&ctx, &repo, Action::DeploymentsTrigger).await { + return v.into_response(); + } + let user = ctx.auth.actor.user_id().unwrap_or("site-key"); + match services.deployment.trigger(&site_id,&trigger_id,user).await{Ok(v)=>(StatusCode::ACCEPTED,Json(v)).into_response(),Err(e)if e.starts_with("deployment_cooldown:")=>(StatusCode::TOO_MANY_REQUESTS,Json(json!({"error":"deployment_cooldown","retry_after_seconds":e.split(':').nth(1).and_then(|v|v.parse::().ok())}))).into_response(),Err(e)if e=="deployment_daily_quota"=>(StatusCode::TOO_MANY_REQUESTS,Json(json!({"error":e}))).into_response(),Err(e)=>(StatusCode::CONFLICT,Json(json!({"error":e}))).into_response()} +} +pub async fn history( + ctx: RequestContext, + Path((site_id, trigger_id)): Path<(String, String)>, + Extension(repo): Extension, + Extension(services): Extension, +) -> Response { + if let Err(v) = require_site_action(&ctx, &repo, Action::DeploymentsRead).await { + return v.into_response(); + } + if !services + .deployment + .list(&site_id) + .await + .unwrap_or_default() + .iter() + .any(|trigger| trigger.id == trigger_id) + { + return StatusCode::NOT_FOUND.into_response(); + } + match services.deployment.history(&trigger_id).await { + Ok(v) => Json(v).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":e}))).into_response(), + } +} +pub async fn delete( + ctx: RequestContext, + Path((site_id, trigger_id)): Path<(String, String)>, + Extension(repo): Extension, + Extension(services): Extension, +) -> Response { + if let Err(value) = require_site_action(&ctx, &repo, Action::DeploymentsWrite).await { + return value.into_response(); + } + match services.deployment.delete(&site_id, &trigger_id).await { + Ok(0) => StatusCode::NOT_FOUND.into_response(), + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":error}))).into_response(), + } +} diff --git a/apps/backend/src/handlers/site_handler.rs b/apps/backend/src/handlers/site_handler.rs index c1500487..6e7bc6d5 100644 --- a/apps/backend/src/handlers/site_handler.rs +++ b/apps/backend/src/handlers/site_handler.rs @@ -71,7 +71,15 @@ pub async fn create_site( .create_site(&payload.name, Some(&payload.storage_provider), &created_by) .await { - Ok(site) => (StatusCode::CREATED, Json(site)).into_response(), + Ok(mut site) => { + if let Some(profile_id) = payload.storage_profile_id.as_deref() { + if let Err(error) = services.storage_profile.assign_site(&site.id, profile_id).await { + return (StatusCode::BAD_REQUEST, Json(json!({"error":error}))).into_response(); + } + site.storage_profile_id = Some(profile_id.to_string()); + } + (StatusCode::CREATED, Json(site)).into_response() + } Err(e) => e.into_response(), } } diff --git a/apps/backend/src/handlers/storage_profile_handler.rs b/apps/backend/src/handlers/storage_profile_handler.rs new file mode 100644 index 00000000..fbd96234 --- /dev/null +++ b/apps/backend/src/handlers/storage_profile_handler.rs @@ -0,0 +1,108 @@ +use crate::{ + middleware::auth::{AuthContext, require_instance_action}, + models::{ + authorization::Action, + storage_profile::{CreateStorageProfile, StorageProbeResult, UpdateStorageProfile}, + }, + repository::Repository, + services::Services, + storage::StorageRegistry, +}; +use axum::{ + Json, + extract::{Extension, Path}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; +use std::sync::Arc; +pub async fn list( + auth: AuthContext, + Extension(repo): Extension, + Extension(services): Extension, +) -> Response { + if let Err(v) = require_instance_action(&auth, &repo, Action::InstanceManage).await { + return v.into_response(); + } + match services.storage_profile.list().await { + Ok(v) => Json(v).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":e}))).into_response(), + } +} +pub async fn create( + auth: AuthContext, + Extension(repo): Extension, + Extension(services): Extension, + Extension(registry): Extension>, + Json(value): Json, +) -> Response { + let user = match require_instance_action(&auth, &repo, Action::InstanceManage).await { + Ok(v) => v, + Err(v) => return v.into_response(), + }; + match services.storage_profile.create(value, &user).await { + Ok(v) => match services.storage_profile.register_all(®istry).await { + Ok(()) => (StatusCode::CREATED, Json(v)).into_response(), + Err(error) => (StatusCode::BAD_REQUEST, Json(json!({"error":error}))).into_response(), + }, + Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error":e}))).into_response(), + } +} +pub async fn delete( + auth: AuthContext, + Path(id): Path, + Extension(repo): Extension, + Extension(services): Extension, + Extension(registry): Extension>, +) -> Response { + if let Err(v) = require_instance_action(&auth, &repo, Action::InstanceManage).await { + return v.into_response(); + } + match services.storage_profile.delete(&id).await { + Ok(_) => { + registry.remove(&id); + StatusCode::NO_CONTENT.into_response() + } + Err(e) => (StatusCode::CONFLICT, Json(json!({"error":e}))).into_response(), + } +} +pub async fn update( + auth: AuthContext, + Path(id): Path, + Extension(repo): Extension, + Extension(services): Extension, + Extension(registry): Extension>, + Json(value): Json, +) -> Response { + if let Err(response) = require_instance_action(&auth, &repo, Action::InstanceManage).await { + return response.into_response(); + } + match services.storage_profile.update(&id, value).await { + Ok(profile) => { + registry.remove(&id); + match services.storage_profile.register_all(®istry).await { + Ok(()) => Json(profile).into_response(), + Err(error) => (StatusCode::BAD_REQUEST, Json(json!({"error":error}))).into_response(), + } + } + Err(error) => (StatusCode::BAD_REQUEST, Json(json!({"error":error}))).into_response(), + } +} +pub async fn probe( + auth: AuthContext, + Path(id): Path, + Extension(repo): Extension, + Extension(services): Extension, +) -> Response { + if let Err(response) = require_instance_action(&auth, &repo, Action::InstanceManage).await { + return response.into_response(); + } + match services.storage_profile.probe(&id).await { + Ok(()) => Json(StorageProbeResult { ok: true }).into_response(), + Err(error) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error":"storage_probe_failed","message":error})), + ) + .into_response(), + } +} diff --git a/apps/backend/src/lib.rs b/apps/backend/src/lib.rs index a64b08c3..90489b71 100644 --- a/apps/backend/src/lib.rs +++ b/apps/backend/src/lib.rs @@ -27,10 +27,12 @@ pub mod models { pub mod access_token; pub mod authorization; pub mod collection; + pub mod deployment; pub mod entry; pub mod file; pub mod session; pub mod site; + pub mod storage_profile; pub mod user; pub mod webhook; } @@ -40,12 +42,14 @@ pub mod handlers { pub mod backup_handler; pub mod collection_handler; pub mod dashboard_handler; + pub mod deployment_handler; pub mod entry_handler; pub mod file_handler; pub mod instance_handler; pub mod settings_handler; pub mod singleton_handler; pub mod site_handler; + pub mod storage_profile_handler; pub mod webhook_handler; } pub mod grpc; diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 66662c14..d1b7c425 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -232,6 +232,7 @@ async fn run_backup(action: &BackupAction) -> Result<(), Box> { } else { let row = service .create_backup(CreateBackupOptions { + storage_profile_id: None, scope, include_files, encrypt: *encrypt, diff --git a/apps/backend/src/mcp/auth.rs b/apps/backend/src/mcp/auth.rs index fd464556..9755595c 100644 --- a/apps/backend/src/mcp/auth.rs +++ b/apps/backend/src/mcp/auth.rs @@ -75,13 +75,21 @@ pub async fn authenticate_mcp_request(mut request: Request, next: Next) -> }; let token = match bearer_token(&request) { - Some(token) if token.starts_with("vcms_site_") => token, - Some(_) => return auth_response(StatusCode::UNAUTHORIZED, "MCP requires a vcms_site_* access token"), + Some(token) if token.starts_with("vcms_site_") || token.starts_with("vcms_pat_") => token, + Some(_) => return auth_response(StatusCode::UNAUTHORIZED, "MCP requires a VCMS access token"), None => return auth_response(StatusCode::UNAUTHORIZED, "Missing Authorization bearer token"), }; match verify_access_token(&token, &repository, &config.token_index_key).await { Ok(actor) => { + let has_mcp = match &actor { + Actor::ApiKey(k) => k.scopes.contains(&crate::models::access_token::TokenScope::McpUse), + Actor::PersonalToken(k) => k.scopes.contains(&crate::models::access_token::TokenScope::McpUse), + Actor::User(_) => false, + }; + if !has_mcp { + return auth_response(StatusCode::FORBIDDEN, "Token is missing mcp.use scope"); + } request.extensions_mut().insert(actor); next.run(request).await } diff --git a/apps/backend/src/mcp/resources/site_schema.rs b/apps/backend/src/mcp/resources/site_schema.rs index 893a3967..a2868a52 100644 --- a/apps/backend/src/mcp/resources/site_schema.rs +++ b/apps/backend/src/mcp/resources/site_schema.rs @@ -13,13 +13,6 @@ fn map_err(e: impl Into) -> McpError { crate::mcp::auth::service_error_to_mcp(e.into()) } -fn require_site_id(actor: &Actor) -> Result { - actor - .bound_site_id() - .map(String::from) - .ok_or_else(|| McpError::invalid_request("No site context", None)) -} - fn resource_uri(site_id: &str, path: &str) -> String { format!("cms://{}{}", site_id, path) } @@ -53,49 +46,39 @@ pub async fn list_resources( actor: &Actor, _request: Option, ) -> Result { - let site_id = require_site_id(actor)?; - - authorization - .require_site_action(actor, &site_id, Action::SiteRead) - .await - .map_err(map_err)?; - - let site = services - .site - .get_site(&site_id) - .await - .map_err(map_err)? - .ok_or_else(|| McpError::invalid_request("Site not found", None))?; - - let site_name = site.name.clone(); - - let mut resources = vec![make_resource( - &resource_uri(&site_id, "/schema"), - &format!("{} Schema", site_name), - &format!("Content schema for {}", site_name), - &format!("Full content schema for {}", site_name), - )]; - - let collections = services.collection.list_collections(&site_id).await.map_err(map_err)?; - - for c in &collections { + let sites = services.site.list_sites_for_actor(actor).await.map_err(map_err)?; + let mut resources = Vec::new(); + for site in sites { + let Some(site_id) = site.get("id").and_then(|value| value.as_str()) else { + continue; + }; + authorization + .require_site_action(actor, site_id, Action::SiteRead) + .await + .map_err(map_err)?; + let site_name = site.get("name").and_then(|value| value.as_str()).unwrap_or(site_id); resources.push(make_resource( - &resource_uri(&site_id, &format!("/collections/{}", c.slug)), - &format!("{}/{}", site_name, c.name), - &format!("Collection: {}", c.name), - &format!("Schema for {} collection", c.name), - )); - } - - let singletons = services.singleton.list_singletons(&site_id).await.map_err(map_err)?; - - for s in &singletons { - resources.push(make_resource( - &resource_uri(&site_id, &format!("/singletons/{}", s.slug)), - &format!("{}/{}", site_name, s.name), - &format!("Singleton: {}", s.name), - &format!("Schema for {} singleton", s.name), + &resource_uri(site_id, "/schema"), + &format!("{} Schema", site_name), + &format!("Content schema for {}", site_name), + &format!("Full content schema for {}", site_name), )); + for collection in services.collection.list_collections(site_id).await.map_err(map_err)? { + resources.push(make_resource( + &resource_uri(site_id, &format!("/collections/{}", collection.slug)), + &format!("{}/{}", site_name, collection.name), + &format!("Collection: {}", collection.name), + &format!("Schema for {} collection", collection.name), + )); + } + for singleton in services.singleton.list_singletons(site_id).await.map_err(map_err)? { + resources.push(make_resource( + &resource_uri(site_id, &format!("/singletons/{}", singleton.slug)), + &format!("{}/{}", site_name, singleton.name), + &format!("Singleton: {}", singleton.name), + &format!("Schema for {} singleton", singleton.name), + )); + } } Ok(ListResourcesResult::with_all_items(resources)) diff --git a/apps/backend/src/mcp/server.rs b/apps/backend/src/mcp/server.rs index 23264eae..12d54c60 100644 --- a/apps/backend/src/mcp/server.rs +++ b/apps/backend/src/mcp/server.rs @@ -1,643 +1,667 @@ -use std::sync::Arc; -use std::time::Instant; - -use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{ - CallToolRequestParams, CallToolResult, Implementation, InitializeRequestParams, ListResourcesResult, - ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, - ServerInfo, -}; -use rmcp::service::RequestContext; -use rmcp::service::RoleServer; -use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_handler, tool_router}; - -use crate::config::Config; -use crate::middleware::auth::Actor; -use crate::repository::Repository; -use crate::services::Services; -use crate::services::authorization::AuthorizationService; -use crate::storage::StorageRegistry; - -use crate::mcp::resources::site_schema; -use crate::mcp::tools::{collection, entry, file, singleton, site, webhook}; - -#[derive(Clone)] -pub struct CmsServer { - pub services: Arc, - pub repository: Arc, - pub storage_registry: Arc, - pub config: Arc, - pub authorizer: Arc, -} - -#[tool_router] -impl CmsServer { - pub fn new( - services: Arc, - repository: Arc, - storage_registry: Arc, - config: Arc, - ) -> Self { - let authorizer = Arc::new(AuthorizationService::new(repository.user.clone())); - Self { - services, - repository, - storage_registry, - config, - authorizer, - } - } - - #[tool(description = "Get details of a specific site by ID")] - async fn get_site( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - site::get_site(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update a site's name")] - async fn update_site( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - site::update_site(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List collections in a site")] - async fn list_collections( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::list_collections(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get a collection by slug")] - async fn get_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::get_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Create a new collection")] - async fn create_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::create_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update a collection's definition")] - async fn update_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::update_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Delete a collection")] - async fn delete_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::delete_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List entries in a site, optionally filtered by collection and status")] - async fn list_entries( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::list_entries(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get an entry by ID")] - async fn get_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::get_entry(&self.authorizer, &self.services, &self.storage_registry, &actor, params).await - } - - #[tool(description = "Create a new entry")] - async fn create_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::create_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update an entry")] - async fn update_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::update_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Delete an entry")] - async fn delete_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::delete_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Publish an entry")] - async fn publish_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::publish_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Unpublish an entry")] - async fn unpublish_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::unpublish_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List singletons in a site")] - async fn list_singletons( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - singleton::list_singletons(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get a singleton by slug")] - async fn get_singleton( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - singleton::get_singleton(&self.authorizer, &self.services, &self.storage_registry, &actor, params).await - } - - #[tool(description = "Update a singleton's data")] - async fn update_singleton( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - singleton::update_singleton(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List files in a site")] - async fn list_files( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - file::list_files(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get file details by ID")] - async fn get_file( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - file::get_file(&self.authorizer, &self.services, &actor, params).await - } - - #[tool( - description = "Create a single-use signed upload URL. To upload: send an HTTP PUT to upload_url with the raw file bytes as the request body and a Content-Type header equal to content_type. The URL expires at expires_at and can be used exactly once. The PUT response body is the created file record (JSON, includes the file id and url)." - )] - async fn create_upload_url( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - let public_base_url = self.public_base_url(&ctx); - file::create_upload_url( - &self.authorizer, - &self.services, - &self.config, - &actor, - public_base_url, - params, - ) - .await - } - - #[tool(description = "Delete a file (soft delete)")] - async fn delete_file( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - file::delete_file(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List webhooks for a site")] - async fn list_webhooks( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::list_webhooks(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Create a webhook")] - async fn create_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::create_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Trigger a webhook")] - async fn trigger_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::trigger_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Delete a webhook")] - async fn delete_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::delete_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get a webhook by ID")] - async fn get_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::get_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update a webhook")] - async fn update_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::update_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List delivery attempts for a webhook")] - async fn list_webhook_deliveries( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::list_webhook_deliveries(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Restore a soft-deleted file")] - async fn restore_file( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - file::restore_file(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List revisions of an entry")] - async fn list_revisions( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::list_revisions(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Restore an entry to a previous revision")] - async fn restore_revision( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::restore_revision(&self.authorizer, &self.services, &actor, params).await - } -} - -use crate::mcp::schema::clean_input_schema; - -#[tool_handler] -impl ServerHandler for CmsServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().enable_resources().build()) - .with_server_info(Implementation::new("cms", env!("CARGO_PKG_VERSION"))) - } - - async fn initialize( - &self, - request: InitializeRequestParams, - mut context: RequestContext, - ) -> Result { - let started = Instant::now(); - self.authenticate_context(&mut context).await?; - if context.peer.peer_info().is_none() { - context.peer.set_peer_info(request.clone()); - } - tracing::info!( - mcp_method = "initialize", - duration_ms = started.elapsed().as_millis(), - outcome = "success", - "MCP operation completed" - ); - Ok(self.get_info().with_protocol_version(request.protocol_version)) - } - - async fn list_tools( - &self, - _request: Option, - mut ctx: RequestContext, - ) -> Result { - let started = Instant::now(); - self.authenticate_context(&mut ctx).await?; - let tools = Self::tool_router() - .list_all() - .into_iter() - .map(|mut tool| { - tool.input_schema = clean_input_schema(tool.input_schema); - tool - }) - .collect(); - let result = ListToolsResult { - tools, - meta: None, - next_cursor: None, - }; - tracing::info!( - mcp_method = "tools/list", - duration_ms = started.elapsed().as_millis(), - outcome = "success", - "MCP operation completed" - ); - Ok(result) - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - mut ctx: RequestContext, - ) -> Result { - let started = Instant::now(); - let tool_name = request.name.to_string(); - self.authenticate_context(&mut ctx).await?; - let tool_context = rmcp::handler::server::tool::ToolCallContext::new(self, request, ctx); - let result = Self::tool_router().call(tool_context).await; - tracing::info!( - mcp_method = "tools/call", - mcp_tool = %tool_name, - duration_ms = started.elapsed().as_millis(), - outcome = if result.is_ok() { "success" } else { "error" }, - "MCP operation completed" - ); - result - } - - async fn list_resources( - &self, - request: Option, - mut ctx: RequestContext, - ) -> Result { - let started = Instant::now(); - let actor = self.authenticate_context(&mut ctx).await?; - let result = site_schema::list_resources(&self.authorizer, &self.services, &actor, request).await; - tracing::info!( - mcp_method = "resources/list", - duration_ms = started.elapsed().as_millis(), - outcome = if result.is_ok() { "success" } else { "error" }, - "MCP operation completed" - ); - result - } - - async fn read_resource( - &self, - request: ReadResourceRequestParams, - mut ctx: RequestContext, - ) -> Result { - let started = Instant::now(); - let actor = self.authenticate_context(&mut ctx).await?; - let result = site_schema::read_resource(&self.authorizer, &self.services, &actor, &request.uri).await; - tracing::info!( - mcp_method = "resources/read", - duration_ms = started.elapsed().as_millis(), - outcome = if result.is_ok() { "success" } else { "error" }, - "MCP operation completed" - ); - result - } -} - -impl CmsServer { - async fn authenticate_context(&self, ctx: &mut RequestContext) -> Result { - self.resolve_actor(ctx) - } - - fn resolve_actor(&self, ctx: &RequestContext) -> Result { - crate::mcp::auth::resolve_actor(ctx) - } - - fn public_base_url(&self, ctx: &RequestContext) -> Option { - if let Some(public_url) = &self.config.public_url { - return Some(public_url.clone()); - } - - let parts = ctx.extensions.get::()?; - let headers = &parts.headers; - let host = headers - .get("x-forwarded-host") - .or_else(|| headers.get("host"))? - .to_str() - .ok()?; - let proto = headers - .get("x-forwarded-proto") - .and_then(|value| value.to_str().ok()) - .unwrap_or("http"); - - Some(format!("{}://{}", proto, host).trim_end_matches('/').to_string()) - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use super::CmsServer; - use crate::mcp::schema::clean_input_schema; - - #[test] - fn tool_router_lists_registered_tools() { - let tools = CmsServer::tool_router().list_all(); - assert!(tools.iter().any(|tool| tool.name == "get_site")); - assert!(!tools.iter().any(|tool| tool.name.contains("token"))); - assert!(!tools.iter().any(|tool| tool.name == "list_sites")); - assert!(tools.len() > 15); - } - - fn all_tools() -> Vec { - CmsServer::tool_router().list_all() - } - - #[test] - fn no_type_null_in_schemas() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - assert_eq!( - cleaned.get("type").and_then(|v| v.as_str()), - Some("object"), - "tool '{}' input_schema must have type 'object'", - tool.name - ); - } - } - - #[test] - fn no_boolean_properties_in_schemas() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - if let Some(props) = cleaned.get("properties").and_then(|v| v.as_object()) { - for (key, value) in props { - assert!( - value.is_object() || value.is_array(), - "tool '{}' property '{}' must be a schema object, got {:?}", - tool.name, - key, - value - ); - } - } - } - } - - #[test] - fn all_input_schemas_are_valid_objects() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - assert!( - cleaned.get("type").and_then(|v| v.as_str()) == Some("object"), - "tool '{}' input_schema must have type 'object'", - tool.name - ); - let has_properties = cleaned.contains_key("properties"); - let has_required = cleaned.contains_key("required"); - assert!( - has_properties || !has_required, - "tool '{}' has 'required' but no 'properties'", - tool.name - ); - } - } - - #[test] - fn no_type_null_anywhere() { - for tool in all_tools() { - let schema_str = serde_json::to_string(&*tool.input_schema).unwrap(); - if schema_str.contains(r#""type":"null""#) { - panic!("tool '{}' still contains \"type\":\"null\"", tool.name,); - } - } - } - - #[test] - fn no_boolean_schema_values() { - for tool in all_tools() { - if let Some(props) = tool.input_schema.get("properties").and_then(|v| v.as_object()) { - for (key, value) in props { - if value.is_boolean() { - panic!("tool '{}' property '{}' is boolean {:?}", tool.name, key, value); - } - } - } - } - } - - #[test] - fn cleaned_schemas_have_no_schema_or_title() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - assert!( - !cleaned.contains_key("$schema"), - "tool '{}' still has $schema", - tool.name - ); - assert!(!cleaned.contains_key("title"), "tool '{}' still has title", tool.name); - } - } - - #[test] - fn required_fields_are_subset_of_properties() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - let props: HashSet<&str> = cleaned - .get("properties") - .and_then(|v| v.as_object()) - .map(|m| m.keys().map(|k| k.as_str()).collect()) - .unwrap_or_default(); - if let Some(required) = cleaned.get("required").and_then(|v| v.as_array()) { - for req in required { - let req_str = req.as_str().unwrap_or(""); - assert!( - props.contains(req_str), - "tool '{}' required field '{}' not in properties", - tool.name, - req_str - ); - } - } - } - } -} +use std::sync::Arc; +use std::time::Instant; + +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Implementation, InitializeRequestParams, ListResourcesResult, + ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, + ServerInfo, +}; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_handler, tool_router}; + +use crate::config::Config; +use crate::middleware::auth::Actor; +use crate::repository::Repository; +use crate::services::Services; +use crate::services::authorization::AuthorizationService; +use crate::storage::StorageRegistry; + +use crate::mcp::resources::site_schema; +use crate::mcp::tools::{collection, entry, file, singleton, site, webhook}; + +#[derive(Clone)] +pub struct CmsServer { + pub services: Arc, + pub repository: Arc, + pub storage_registry: Arc, + pub config: Arc, + pub authorizer: Arc, +} + +#[tool_router] +impl CmsServer { + pub fn new( + services: Arc, + repository: Arc, + storage_registry: Arc, + config: Arc, + ) -> Self { + let authorizer = Arc::new(AuthorizationService::new(repository.user.clone())); + Self { + services, + repository, + storage_registry, + config, + authorizer, + } + } + + #[tool(description = "List sites accessible to the authenticated user")] + async fn list_sites(&self, ctx: RequestContext) -> Result { + let actor = self.resolve_actor(&ctx)?; + match self.services.site.list_sites_for_actor(&actor).await { + Ok(v) => crate::mcp::auth::ok_result(&v), + Err(e) => Err(crate::mcp::auth::map_err(e)), + } + } + + #[tool(description = "Get details of a specific site by ID")] + async fn get_site( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + site::get_site(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Update a site's name")] + async fn update_site( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + site::update_site(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List collections in a site")] + async fn list_collections( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::list_collections(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get a collection by slug")] + async fn get_collection( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::get_collection(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Create a new collection")] + async fn create_collection( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::create_collection(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Update a collection's definition")] + async fn update_collection( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::update_collection(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Delete a collection")] + async fn delete_collection( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::delete_collection(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List entries in a site, optionally filtered by collection and status")] + async fn list_entries( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::list_entries(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get an entry by ID")] + async fn get_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::get_entry(&self.authorizer, &self.services, &self.storage_registry, &actor, params).await + } + + #[tool(description = "Create a new entry")] + async fn create_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::create_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Update an entry")] + async fn update_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::update_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Delete an entry")] + async fn delete_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::delete_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Publish an entry")] + async fn publish_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::publish_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Unpublish an entry")] + async fn unpublish_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::unpublish_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List singletons in a site")] + async fn list_singletons( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + singleton::list_singletons(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get a singleton by slug")] + async fn get_singleton( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + singleton::get_singleton(&self.authorizer, &self.services, &self.storage_registry, &actor, params).await + } + + #[tool(description = "Update a singleton's data")] + async fn update_singleton( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + singleton::update_singleton(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List files in a site")] + async fn list_files( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + file::list_files(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get file details by ID")] + async fn get_file( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + file::get_file(&self.authorizer, &self.services, &actor, params).await + } + + #[tool( + description = "Create a single-use signed upload URL. To upload: send an HTTP PUT to upload_url with the raw file bytes as the request body and a Content-Type header equal to content_type. The URL expires at expires_at and can be used exactly once. The PUT response body is the created file record (JSON, includes the file id and url)." + )] + async fn create_upload_url( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + let public_base_url = self.public_base_url(&ctx); + file::create_upload_url( + &self.authorizer, + &self.services, + &self.config, + &actor, + public_base_url, + params, + ) + .await + } + + #[tool(description = "Delete a file (soft delete)")] + async fn delete_file( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + file::delete_file(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List webhooks for a site")] + async fn list_webhooks( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::list_webhooks(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Create a webhook")] + async fn create_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::create_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Trigger a webhook")] + async fn trigger_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::trigger_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Delete a webhook")] + async fn delete_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::delete_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get a webhook by ID")] + async fn get_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::get_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Update a webhook")] + async fn update_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::update_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List delivery attempts for a webhook")] + async fn list_webhook_deliveries( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::list_webhook_deliveries(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Restore a soft-deleted file")] + async fn restore_file( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + file::restore_file(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List revisions of an entry")] + async fn list_revisions( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::list_revisions(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Restore an entry to a previous revision")] + async fn restore_revision( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::restore_revision(&self.authorizer, &self.services, &actor, params).await + } +} + +use crate::mcp::schema::clean_input_schema; + +#[tool_handler] +impl ServerHandler for CmsServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().enable_resources().build()) + .with_server_info(Implementation::new("cms", env!("CARGO_PKG_VERSION"))) + } + + async fn initialize( + &self, + request: InitializeRequestParams, + mut context: RequestContext, + ) -> Result { + let started = Instant::now(); + self.authenticate_context(&mut context).await?; + if context.peer.peer_info().is_none() { + context.peer.set_peer_info(request.clone()); + } + tracing::info!( + mcp_method = "initialize", + duration_ms = started.elapsed().as_millis(), + outcome = "success", + "MCP operation completed" + ); + Ok(self.get_info().with_protocol_version(request.protocol_version)) + } + + async fn list_tools( + &self, + _request: Option, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + self.authenticate_context(&mut ctx).await?; + let tools = Self::tool_router() + .list_all() + .into_iter() + .map(|mut tool| { + tool.input_schema = clean_input_schema(tool.input_schema); + tool + }) + .collect(); + let result = ListToolsResult { + tools, + meta: None, + next_cursor: None, + }; + tracing::info!( + mcp_method = "tools/list", + duration_ms = started.elapsed().as_millis(), + outcome = "success", + "MCP operation completed" + ); + Ok(result) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + let tool_name = request.name.to_string(); + self.authenticate_context(&mut ctx).await?; + let tool_context = rmcp::handler::server::tool::ToolCallContext::new(self, request, ctx); + let result = Self::tool_router().call(tool_context).await; + tracing::info!( + mcp_method = "tools/call", + mcp_tool = %tool_name, + duration_ms = started.elapsed().as_millis(), + outcome = if result.is_ok() { "success" } else { "error" }, + "MCP operation completed" + ); + result + } + + async fn list_resources( + &self, + request: Option, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + let actor = self.authenticate_context(&mut ctx).await?; + let result = site_schema::list_resources(&self.authorizer, &self.services, &actor, request).await; + tracing::info!( + mcp_method = "resources/list", + duration_ms = started.elapsed().as_millis(), + outcome = if result.is_ok() { "success" } else { "error" }, + "MCP operation completed" + ); + result + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + let actor = self.authenticate_context(&mut ctx).await?; + let result = site_schema::read_resource(&self.authorizer, &self.services, &actor, &request.uri).await; + tracing::info!( + mcp_method = "resources/read", + duration_ms = started.elapsed().as_millis(), + outcome = if result.is_ok() { "success" } else { "error" }, + "MCP operation completed" + ); + result + } +} + +impl CmsServer { + async fn authenticate_context(&self, ctx: &mut RequestContext) -> Result { + self.resolve_actor(ctx) + } + + fn resolve_actor(&self, ctx: &RequestContext) -> Result { + crate::mcp::auth::resolve_actor(ctx) + } + + fn public_base_url(&self, ctx: &RequestContext) -> Option { + if let Some(public_url) = &self.config.public_url { + return Some(public_url.clone()); + } + + let parts = ctx.extensions.get::()?; + let headers = &parts.headers; + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host"))? + .to_str() + .ok()?; + let proto = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .unwrap_or("http"); + + Some(format!("{}://{}", proto, host).trim_end_matches('/').to_string()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::CmsServer; + use crate::mcp::schema::clean_input_schema; + + #[test] + fn tool_router_lists_registered_tools() { + let tools = CmsServer::tool_router().list_all(); + assert!(tools.iter().any(|tool| tool.name == "get_site")); + assert!(!tools.iter().any(|tool| tool.name.contains("token"))); + assert!(tools.iter().any(|tool| tool.name == "list_sites")); + assert!(tools.len() > 15); + } + + fn all_tools() -> Vec { + CmsServer::tool_router().list_all() + } + + #[test] + fn no_type_null_in_schemas() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + assert_eq!( + cleaned.get("type").and_then(|v| v.as_str()), + Some("object"), + "tool '{}' input_schema must have type 'object'", + tool.name + ); + } + } + + #[test] + fn no_boolean_properties_in_schemas() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + if let Some(props) = cleaned.get("properties").and_then(|v| v.as_object()) { + for (key, value) in props { + assert!( + value.is_object() || value.is_array(), + "tool '{}' property '{}' must be a schema object, got {:?}", + tool.name, + key, + value + ); + } + } + } + } + + #[test] + fn all_input_schemas_are_valid_objects() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + assert!( + cleaned.get("type").and_then(|v| v.as_str()) == Some("object"), + "tool '{}' input_schema must have type 'object'", + tool.name + ); + let has_properties = cleaned.contains_key("properties"); + let has_required = cleaned.contains_key("required"); + assert!( + has_properties || !has_required, + "tool '{}' has 'required' but no 'properties'", + tool.name + ); + } + } + + #[test] + fn no_type_null_anywhere() { + for tool in all_tools() { + let schema_str = serde_json::to_string(&*tool.input_schema).unwrap(); + if schema_str.contains(r#""type":"null""#) { + panic!("tool '{}' still contains \"type\":\"null\"", tool.name,); + } + } + } + + #[test] + fn no_boolean_schema_values() { + for tool in all_tools() { + if let Some(props) = tool.input_schema.get("properties").and_then(|v| v.as_object()) { + for (key, value) in props { + if value.is_boolean() { + panic!("tool '{}' property '{}' is boolean {:?}", tool.name, key, value); + } + } + } + } + } + + #[test] + fn cleaned_schemas_have_no_schema_or_title() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + assert!( + !cleaned.contains_key("$schema"), + "tool '{}' still has $schema", + tool.name + ); + assert!(!cleaned.contains_key("title"), "tool '{}' still has title", tool.name); + } + } + + #[test] + fn required_fields_are_subset_of_properties() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + let props: HashSet<&str> = cleaned + .get("properties") + .and_then(|v| v.as_object()) + .map(|m| m.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + if let Some(required) = cleaned.get("required").and_then(|v| v.as_array()) { + for req in required { + let req_str = req.as_str().unwrap_or(""); + assert!( + props.contains(req_str), + "tool '{}' required field '{}' not in properties", + tool.name, + req_str + ); + } + } + } + } + + #[test] + fn every_site_tool_requires_explicit_site_id() { + for tool in all_tools().into_iter().filter(|tool| tool.name != "list_sites") { + let cleaned = clean_input_schema(tool.input_schema); + assert!( + cleaned + .get("required") + .and_then(|value| value.as_array()) + .is_some_and(|required| required.iter().any(|value| value.as_str() == Some("site_id"))), + "tool '{}' must require site_id", + tool.name + ); + } + } +} diff --git a/apps/backend/src/mcp/tools/collection.rs b/apps/backend/src/mcp/tools/collection.rs index 97c8119e..c126e123 100644 --- a/apps/backend/src/mcp/tools/collection.rs +++ b/apps/backend/src/mcp/tools/collection.rs @@ -12,23 +12,18 @@ use crate::middleware::auth::Actor; use crate::models::authorization::Action; use crate::services::{Services, authorization::AuthorizationService}; -fn require_site_id(actor: &Actor) -> Result { - actor - .bound_site_id() - .map(String::from) - .ok_or_else(|| McpError::invalid_request("No site context", None)) -} - #[derive(Debug, Deserialize, JsonSchema)] -pub struct ListCollectionsParams {} +pub struct ListCollectionsParams { + pub site_id: String, +} pub async fn list_collections( authorization: &Arc, services: &Arc, actor: &Actor, - _params: Parameters, + params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id; if let Err(e) = authorization .require_site_action(actor, &site_id, Action::SchemaRead) .await @@ -43,6 +38,7 @@ pub async fn list_collections( #[derive(Debug, Deserialize, JsonSchema)] pub struct GetCollectionParams { + pub site_id: String, pub slug: String, } @@ -52,7 +48,7 @@ pub async fn get_collection( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::SchemaRead) .await @@ -70,6 +66,7 @@ pub async fn get_collection( #[derive(Debug, Deserialize, JsonSchema)] pub struct CreateCollectionParams { + pub site_id: String, pub name: String, pub slug: Option, #[schemars(with = "ArbitraryJson")] @@ -83,7 +80,7 @@ pub async fn create_collection( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::SchemaWrite) .await @@ -110,6 +107,7 @@ pub async fn create_collection( #[derive(Debug, Deserialize, JsonSchema)] pub struct UpdateCollectionParams { + pub site_id: String, pub slug: String, pub name: Option, pub new_slug: Option, @@ -123,7 +121,7 @@ pub async fn update_collection( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::SchemaWrite) .await @@ -156,6 +154,7 @@ pub async fn update_collection( #[derive(Debug, Deserialize, JsonSchema)] pub struct DeleteCollectionParams { + pub site_id: String, pub slug: String, } @@ -165,7 +164,7 @@ pub async fn delete_collection( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::SchemaWrite) .await diff --git a/apps/backend/src/mcp/tools/entry.rs b/apps/backend/src/mcp/tools/entry.rs index 8f045ce6..f290a6d9 100644 --- a/apps/backend/src/mcp/tools/entry.rs +++ b/apps/backend/src/mcp/tools/entry.rs @@ -15,15 +15,9 @@ use crate::services::entry::UpdateEntryInput; use crate::services::{Services, authorization::AuthorizationService}; use crate::storage::StorageRegistry; -fn require_site_id(actor: &Actor) -> Result { - actor - .bound_site_id() - .map(String::from) - .ok_or_else(|| McpError::invalid_request("No site context", None)) -} - #[derive(Debug, Deserialize, JsonSchema)] pub struct ListEntriesParams { + pub site_id: String, pub collection_slug: Option, pub published_only: Option, pub page: Option, @@ -37,7 +31,7 @@ pub async fn list_entries( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentRead) .await @@ -73,6 +67,7 @@ pub async fn list_entries( #[derive(Debug, Deserialize, JsonSchema)] pub struct GetEntryParams { + pub site_id: String, pub id: String, } @@ -83,7 +78,7 @@ pub async fn get_entry( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentRead) .await @@ -101,6 +96,7 @@ pub async fn get_entry( #[derive(Debug, Deserialize, JsonSchema)] pub struct CreateEntryParams { + pub site_id: String, pub collection_id: String, #[schemars(with = "ArbitraryJson")] pub values: serde_json::Value, @@ -114,7 +110,7 @@ pub async fn create_entry( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentWrite) .await @@ -144,6 +140,7 @@ pub async fn create_entry( #[derive(Debug, Deserialize, JsonSchema)] pub struct UpdateEntryParams { + pub site_id: String, pub id: String, #[schemars(with = "ArbitraryJson")] pub values: Option, @@ -158,7 +155,7 @@ pub async fn update_entry( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentWrite) .await @@ -185,6 +182,7 @@ pub async fn update_entry( #[derive(Debug, Deserialize, JsonSchema)] pub struct DeleteEntryParams { + pub site_id: String, pub id: String, } @@ -194,7 +192,7 @@ pub async fn delete_entry( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentWrite) .await @@ -217,6 +215,7 @@ pub async fn delete_entry( #[derive(Debug, Deserialize, JsonSchema)] pub struct PublishEntryParams { + pub site_id: String, pub id: String, } @@ -226,7 +225,7 @@ pub async fn publish_entry( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentWrite) .await @@ -241,6 +240,7 @@ pub async fn publish_entry( #[derive(Debug, Deserialize, JsonSchema)] pub struct UnpublishEntryParams { + pub site_id: String, pub id: String, } @@ -250,7 +250,7 @@ pub async fn unpublish_entry( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentWrite) .await @@ -265,6 +265,7 @@ pub async fn unpublish_entry( #[derive(Debug, Deserialize, JsonSchema)] pub struct ListRevisionsParams { + pub site_id: String, pub entry_id: String, pub page: Option, pub per_page: Option, @@ -276,7 +277,7 @@ pub async fn list_revisions( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentRead) .await @@ -305,6 +306,7 @@ pub async fn list_revisions( #[derive(Debug, Deserialize, JsonSchema)] pub struct RestoreRevisionParams { + pub site_id: String, pub entry_id: String, pub revision_number: i64, } @@ -315,7 +317,7 @@ pub async fn restore_revision( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentWrite) .await diff --git a/apps/backend/src/mcp/tools/file.rs b/apps/backend/src/mcp/tools/file.rs index 8019c32d..0773a34e 100644 --- a/apps/backend/src/mcp/tools/file.rs +++ b/apps/backend/src/mcp/tools/file.rs @@ -13,15 +13,9 @@ use crate::models::authorization::Action; use crate::services::{Services, authorization::AuthorizationService}; use crate::signed_upload::SignedUploadToken; -fn require_site_id(actor: &Actor) -> Result { - actor - .bound_site_id() - .map(String::from) - .ok_or_else(|| McpError::invalid_request("No site context", None)) -} - #[derive(Debug, Deserialize, JsonSchema)] pub struct ListFilesParams { + pub site_id: String, #[serde(default)] pub page: Option, #[serde(default)] @@ -40,7 +34,7 @@ pub async fn list_files( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::FilesRead) .await @@ -74,6 +68,7 @@ pub async fn list_files( #[derive(Debug, Deserialize, JsonSchema)] pub struct GetFileParams { + pub site_id: String, pub file_id: String, } @@ -83,7 +78,7 @@ pub async fn get_file( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::FilesRead) .await @@ -102,6 +97,7 @@ pub async fn get_file( #[derive(Debug, Deserialize, JsonSchema)] pub struct CreateUploadUrlParams { + pub site_id: String, pub filename: String, #[serde(default = "default_content_type")] pub content_type: String, @@ -119,7 +115,7 @@ pub async fn create_upload_url( public_base_url: Option, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::FilesWrite) .await @@ -171,6 +167,7 @@ pub async fn create_upload_url( #[derive(Debug, Deserialize, JsonSchema)] pub struct DeleteFileParams { + pub site_id: String, pub file_id: String, } @@ -180,7 +177,7 @@ pub async fn delete_file( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::FilesWrite) .await @@ -196,6 +193,7 @@ pub async fn delete_file( #[derive(Debug, Deserialize, JsonSchema)] pub struct RestoreFileParams { + pub site_id: String, pub file_id: String, } @@ -205,7 +203,7 @@ pub async fn restore_file( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::FilesWrite) .await diff --git a/apps/backend/src/mcp/tools/singleton.rs b/apps/backend/src/mcp/tools/singleton.rs index 5baa110e..390b7654 100644 --- a/apps/backend/src/mcp/tools/singleton.rs +++ b/apps/backend/src/mcp/tools/singleton.rs @@ -13,23 +13,18 @@ use crate::models::authorization::Action; use crate::services::{Services, authorization::AuthorizationService}; use crate::storage::StorageRegistry; -fn require_site_id(actor: &Actor) -> Result { - actor - .bound_site_id() - .map(String::from) - .ok_or_else(|| McpError::invalid_request("No site context", None)) -} - #[derive(Debug, Deserialize, JsonSchema)] -pub struct ListSingletonsParams {} +pub struct ListSingletonsParams { + pub site_id: String, +} pub async fn list_singletons( authorization: &Arc, services: &Arc, actor: &Actor, - _params: Parameters, + params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id; if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentRead) .await @@ -44,6 +39,7 @@ pub async fn list_singletons( #[derive(Debug, Deserialize, JsonSchema)] pub struct GetSingletonParams { + pub site_id: String, pub slug: String, } @@ -54,7 +50,7 @@ pub async fn get_singleton( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentRead) .await @@ -88,6 +84,7 @@ pub async fn get_singleton( #[derive(Debug, Deserialize, JsonSchema)] pub struct UpdateSingletonParams { + pub site_id: String, pub slug: String, #[schemars(with = "ArbitraryJson")] pub data: serde_json::Value, @@ -100,7 +97,7 @@ pub async fn update_singleton( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::ContentWrite) .await diff --git a/apps/backend/src/mcp/tools/site.rs b/apps/backend/src/mcp/tools/site.rs index 2866f71a..89e114aa 100644 --- a/apps/backend/src/mcp/tools/site.rs +++ b/apps/backend/src/mcp/tools/site.rs @@ -11,23 +11,18 @@ use crate::middleware::auth::Actor; use crate::models::authorization::Action; use crate::services::{Services, authorization::AuthorizationService}; -fn require_site_id(actor: &Actor) -> Result { - actor - .bound_site_id() - .map(String::from) - .ok_or_else(|| McpError::invalid_request("No site context", None)) -} - #[derive(Debug, Deserialize, JsonSchema)] -pub struct GetSiteParams {} +pub struct GetSiteParams { + pub site_id: String, +} pub async fn get_site( authorization: &Arc, services: &Arc, actor: &Actor, - _params: Parameters, + params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id; if let Err(e) = authorization .require_site_action(actor, &site_id, Action::SiteRead) .await @@ -45,6 +40,7 @@ pub async fn get_site( #[derive(Debug, Deserialize, JsonSchema)] pub struct UpdateSiteParams { + pub site_id: String, pub name: Option, } @@ -54,7 +50,7 @@ pub async fn update_site( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::SiteManage) .await diff --git a/apps/backend/src/mcp/tools/webhook.rs b/apps/backend/src/mcp/tools/webhook.rs index d3143ac6..a98dcf83 100644 --- a/apps/backend/src/mcp/tools/webhook.rs +++ b/apps/backend/src/mcp/tools/webhook.rs @@ -13,23 +13,18 @@ use crate::middleware::auth::Actor; use crate::models::authorization::Action; use crate::services::{Services, authorization::AuthorizationService}; -fn require_site_id(actor: &Actor) -> Result { - actor - .bound_site_id() - .map(String::from) - .ok_or_else(|| McpError::invalid_request("No site context", None)) -} - #[derive(Debug, Deserialize, JsonSchema)] -pub struct ListWebhooksParams {} +pub struct ListWebhooksParams { + pub site_id: String, +} pub async fn list_webhooks( authorization: &Arc, services: &Arc, actor: &Actor, - _params: Parameters, + params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id; if let Err(e) = authorization .require_site_action(actor, &site_id, Action::WebhooksRead) .await @@ -44,6 +39,7 @@ pub async fn list_webhooks( #[derive(Debug, Deserialize, JsonSchema)] pub struct GetWebhookParams { + pub site_id: String, pub webhook_id: String, } @@ -53,7 +49,7 @@ pub async fn get_webhook( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::WebhooksRead) .await @@ -71,6 +67,7 @@ pub async fn get_webhook( #[derive(Debug, Deserialize, JsonSchema)] pub struct CreateWebhookParams { + pub site_id: String, pub label: String, pub url: String, #[schemars(with = "ArbitraryJson")] @@ -83,7 +80,7 @@ pub async fn create_webhook( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::WebhooksWrite) .await @@ -107,6 +104,7 @@ pub async fn create_webhook( #[derive(Debug, Deserialize, JsonSchema)] pub struct UpdateWebhookParams { + pub site_id: String, pub webhook_id: String, pub label: Option, pub url: Option, @@ -120,7 +118,7 @@ pub async fn update_webhook( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::WebhooksWrite) .await @@ -146,6 +144,7 @@ pub async fn update_webhook( #[derive(Debug, Deserialize, JsonSchema)] pub struct TriggerWebhookParams { + pub site_id: String, pub webhook_id: String, } @@ -155,7 +154,7 @@ pub async fn trigger_webhook( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::WebhooksWrite) .await @@ -174,6 +173,7 @@ pub async fn trigger_webhook( #[derive(Debug, Deserialize, JsonSchema)] pub struct DeleteWebhookParams { + pub site_id: String, pub webhook_id: String, } @@ -183,7 +183,7 @@ pub async fn delete_webhook( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::WebhooksWrite) .await @@ -198,6 +198,7 @@ pub async fn delete_webhook( #[derive(Debug, Deserialize, JsonSchema)] pub struct ListWebhookDeliveriesParams { + pub site_id: String, pub webhook_id: String, pub page: Option, pub per_page: Option, @@ -209,7 +210,7 @@ pub async fn list_webhook_deliveries( actor: &Actor, params: Parameters, ) -> Result { - let site_id = require_site_id(actor)?; + let site_id = params.0.site_id.clone(); if let Err(e) = authorization .require_site_action(actor, &site_id, Action::WebhooksRead) .await diff --git a/apps/backend/src/middleware/api_auth.rs b/apps/backend/src/middleware/api_auth.rs index f457a08d..86183deb 100644 --- a/apps/backend/src/middleware/api_auth.rs +++ b/apps/backend/src/middleware/api_auth.rs @@ -19,7 +19,7 @@ pub async fn api_auth_middleware(mut request: Request, next: Next) -> Response { .map(|v| v.trim().to_string()); let token = match auth_header { - Some(t) if t.starts_with("vcms_site_") => t, + Some(t) if t.starts_with("vcms_site_") || t.starts_with("vcms_pat_") => t, _ => { return ( StatusCode::UNAUTHORIZED, diff --git a/apps/backend/src/middleware/auth.rs b/apps/backend/src/middleware/auth.rs index d9e11cb7..1674205f 100644 --- a/apps/backend/src/middleware/auth.rs +++ b/apps/backend/src/middleware/auth.rs @@ -12,7 +12,7 @@ use tracing::Span; use crate::config::Config; use crate::middleware::error::AuthError; -use crate::models::access_token::AccessTokenPermission; +use crate::models::access_token::{AccessTokenPermission, TokenScope, TokenScopes, decode_scopes}; use crate::models::authorization::{Action, Authorizer, InstanceRole, SiteRole}; use crate::repository::Repository; @@ -32,19 +32,30 @@ pub struct UserActor { pub struct ApiKeyActor { pub token_id: String, pub site_id: String, + pub scopes: TokenScopes, pub permission: AccessTokenPermission, } +#[derive(Debug, Clone)] +pub struct PersonalTokenActor { + pub token_id: String, + pub user_id: String, + pub scopes: TokenScopes, + pub site_id: Option, +} + #[derive(Debug, Clone)] pub enum Actor { User(UserActor), ApiKey(ApiKeyActor), + PersonalToken(PersonalTokenActor), } impl Actor { pub fn user_id(&self) -> Option<&str> { match self { Self::User(u) => Some(&u.user_id), + Self::PersonalToken(t) => Some(&t.user_id), _ => None, } } @@ -52,6 +63,7 @@ impl Actor { pub fn bound_site_id(&self) -> Option<&str> { match self { Self::ApiKey(k) => Some(&k.site_id), + Self::PersonalToken(t) => t.site_id.as_deref(), _ => None, } } @@ -63,6 +75,7 @@ impl Actor { pub enum AuthMethod { Session, ApiKey, + PersonalToken, } #[derive(Debug, Clone)] @@ -237,6 +250,33 @@ pub(crate) async fn verify_access_token( repository: &Repository, hmac_secret: &str, ) -> Result)> { + if token.starts_with("vcms_pat_") { + let prefix: String = token.chars().take(TOKEN_PREFIX_LEN).collect(); + let rows = repository + .access_token + .find_personal_by_prefix(&prefix) + .await + .map_err(|_| AuthError::unauthorized("Internal server error"))?; + for (id, user_id, stored_hash, expires_at, revoked_at, scopes_json, last_used_at) in rows { + if !bcrypt::verify(token, &stored_hash).unwrap_or(false) { + continue; + } + if revoked_at.is_some() || !is_token_not_expired(expires_at.as_deref()) { + return Err(AuthError::unauthorized("Personal access token is expired or revoked")); + } + if needs_touch(last_used_at.as_deref()) { + let _ = repository.access_token.touch_personal(&id).await; + } + let scopes = decode_scopes(&scopes_json).map_err(|_| AuthError::unauthorized("Invalid token scopes"))?; + return Ok(Actor::PersonalToken(PersonalTokenActor { + token_id: id, + user_id, + scopes, + site_id: None, + })); + } + return Err(AuthError::unauthorized("Invalid personal access token")); + } if !token.starts_with("vcms_site_") { return Err(AuthError::unauthorized("Invalid access token")); } @@ -268,9 +308,7 @@ pub(crate) async fn verify_access_token( return Err(AuthError::unauthorized("Access token has expired")); } - let permission = permission - .parse::() - .map_err(|_| AuthError::unauthorized("Invalid access token"))?; + let scopes = decode_scopes(&permission).map_err(|_| AuthError::unauthorized("Invalid access token scopes"))?; if needs_touch(last_used_at.as_deref()) { let _ = repository.access_token.update_last_used(&token_id).await; @@ -280,7 +318,21 @@ pub(crate) async fn verify_access_token( return Ok(Actor::ApiKey(ApiKeyActor { token_id, site_id, - permission, + permission: if scopes.iter().any(|s| { + matches!( + s, + TokenScope::ContentWrite + | TokenScope::FilesWrite + | TokenScope::SchemaWrite + | TokenScope::WebhooksWrite + | TokenScope::DeploymentsWrite + ) + }) { + AccessTokenPermission::Write + } else { + AccessTokenPermission::Read + }, + scopes, })); } @@ -332,16 +384,46 @@ pub async fn require_site_action( ) -> Result<(), (StatusCode, Json)> { match &ctx.auth.actor { Actor::ApiKey(key) => { - if Authorizer::allows_api_key(key.permission.can_write(), action) { + if !Authorizer::token_hard_denied(action) + && scope_for_action(action).is_some_and(|scope| key.scopes.contains(&scope)) + { Ok(()) } else { Err(AuthError::insufficient_permission("write")) } } + Actor::PersonalToken(token) => { + if Authorizer::token_hard_denied(action) + || !scope_for_action(action).is_some_and(|scope| token.scopes.contains(&scope)) + { + return Err(AuthError::insufficient_permission("token scope")); + } + check_site_action_repo(repository, &token.user_id, &ctx.site_id, action).await + } Actor::User(user) => check_site_action_repo(repository, &user.user_id, &ctx.site_id, action).await, } } +pub const fn scope_for_action(action: Action) -> Option { + Some(match action { + Action::SiteRead => TokenScope::SiteRead, + Action::SiteManage => TokenScope::SiteSettingsWrite, + Action::ContentRead => TokenScope::ContentRead, + Action::ContentWrite => TokenScope::ContentWrite, + Action::SchemaRead => TokenScope::SchemaRead, + Action::SchemaWrite => TokenScope::SchemaWrite, + Action::FilesRead => TokenScope::FilesRead, + Action::FilesWrite => TokenScope::FilesWrite, + Action::WebhooksRead => TokenScope::WebhooksRead, + Action::WebhooksWrite => TokenScope::WebhooksWrite, + Action::WebhooksTrigger => TokenScope::WebhooksTrigger, + Action::DeploymentsRead => TokenScope::DeploymentsRead, + Action::DeploymentsWrite => TokenScope::DeploymentsWrite, + Action::DeploymentsTrigger => TokenScope::DeploymentsTrigger, + _ => return None, + }) +} + pub async fn require_user_action( ctx: &RequestContext, repository: &Repository, @@ -466,6 +548,7 @@ mod tests { token_id: "tok-1".into(), site_id: "site-42".into(), permission: AccessTokenPermission::Read, + scopes: AccessTokenPermission::Read.into(), }); assert_eq!(actor.bound_site_id(), Some("site-42")); assert!(actor.user_id().is_none()); diff --git a/apps/backend/src/middleware/site_resolver.rs b/apps/backend/src/middleware/site_resolver.rs index a7c9e857..923f1eee 100644 --- a/apps/backend/src/middleware/site_resolver.rs +++ b/apps/backend/src/middleware/site_resolver.rs @@ -31,6 +31,7 @@ pub async fn api_site_resolver(mut request: Request, next: Next) -> Response { let site_id = match &actor { Actor::ApiKey(k) => k.site_id.clone(), + Actor::PersonalToken(_) => match request.headers().get("x-vcms-site").and_then(|v|v.to_str().ok()) { Some(v) if !v.is_empty()=>v.to_string(), _=>return (StatusCode::BAD_REQUEST,Json(serde_json::json!({"error":"missing_site_context","message":"X-VCMS-Site is required for personal tokens"}))).into_response() }, _ => { return ( StatusCode::FORBIDDEN, @@ -43,10 +44,31 @@ pub async fn api_site_resolver(mut request: Request, next: Next) -> Response { } }; - let auth = AuthContext { - actor, - auth_method: AuthMethod::ApiKey, + if let Actor::PersonalToken(token) = &actor { + let repository = match request.extensions().get::() { + Some(value) => value.clone(), + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error":"internal_error"})), + ) + .into_response(); + } + }; + if let Err((status, error)) = + crate::middleware::auth::check_site_action_repo(&repository, &token.user_id, &site_id, Action::SiteRead) + .await + { + return (status, error).into_response(); + } + } + + let auth_method = if matches!(actor, Actor::PersonalToken(_)) { + AuthMethod::PersonalToken + } else { + AuthMethod::ApiKey }; + let auth = AuthContext { actor, auth_method }; let ctx = RequestContext { site_id, auth }; request.extensions_mut().insert(ctx); diff --git a/apps/backend/src/models/access_token.rs b/apps/backend/src/models/access_token.rs index c895937f..5eb96c3a 100644 --- a/apps/backend/src/models/access_token.rs +++ b/apps/backend/src/models/access_token.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use serde::{Deserialize, Serialize}; use sqlx::FromRow; use utoipa::ToSchema; @@ -9,6 +11,87 @@ pub enum AccessTokenPermission { Write, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, ToSchema)] +pub enum TokenScope { + #[serde(rename = "site.read")] + SiteRead, + #[serde(rename = "site.settings.read")] + SiteSettingsRead, + #[serde(rename = "site.settings.write")] + SiteSettingsWrite, + #[serde(rename = "content.read")] + ContentRead, + #[serde(rename = "content.write")] + ContentWrite, + #[serde(rename = "files.read")] + FilesRead, + #[serde(rename = "files.write")] + FilesWrite, + #[serde(rename = "schema.read")] + SchemaRead, + #[serde(rename = "schema.write")] + SchemaWrite, + #[serde(rename = "webhooks.read")] + WebhooksRead, + #[serde(rename = "webhooks.write")] + WebhooksWrite, + #[serde(rename = "webhooks.trigger")] + WebhooksTrigger, + #[serde(rename = "deployments.read")] + DeploymentsRead, + #[serde(rename = "deployments.write")] + DeploymentsWrite, + #[serde(rename = "deployments.trigger")] + DeploymentsTrigger, + #[serde(rename = "mcp.use")] + McpUse, +} + +pub type TokenScopes = BTreeSet; +impl From for TokenScopes { + fn from(value: AccessTokenPermission) -> Self { + match value { + AccessTokenPermission::Read => decode_scopes("read").unwrap_or_default(), + AccessTokenPermission::Write => decode_scopes("write").unwrap_or_default(), + } + } +} + +pub fn encode_scopes(scopes: &TokenScopes) -> Result { + serde_json::to_string(scopes) +} + +pub fn decode_scopes(value: &str) -> Result { + // Existing development keys remain readable during rolling developer upgrades. + match value { + "read" => Ok([ + TokenScope::SiteRead, + TokenScope::ContentRead, + TokenScope::FilesRead, + TokenScope::SchemaRead, + ] + .into_iter() + .collect()), + "write" => Ok([ + TokenScope::SiteRead, + TokenScope::SiteSettingsRead, + TokenScope::SiteSettingsWrite, + TokenScope::ContentRead, + TokenScope::ContentWrite, + TokenScope::FilesRead, + TokenScope::FilesWrite, + TokenScope::SchemaRead, + TokenScope::SchemaWrite, + TokenScope::WebhooksRead, + TokenScope::WebhooksWrite, + TokenScope::WebhooksTrigger, + ] + .into_iter() + .collect()), + _ => serde_json::from_str(value), + } +} + pub type ApiKeyPermission = AccessTokenPermission; impl AccessTokenPermission { @@ -61,7 +144,8 @@ pub struct AccessToken { #[derive(Deserialize, ToSchema)] pub struct CreateSiteToken { pub name: String, - pub permission: AccessTokenPermission, + pub scopes: TokenScopes, + pub expires_at: Option, } #[derive(Serialize, ToSchema)] @@ -72,5 +156,45 @@ pub struct AccessTokenResponse { pub token: String, pub token_prefix: String, pub permission: String, + pub scopes: TokenScopes, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, FromRow, ToSchema)] +pub struct PersonalAccessToken { + pub id: String, + pub user_id: String, + pub name: String, + pub token_prefix: String, + pub scopes_json: String, + pub last_used_at: Option, pub created_at: String, + pub expires_at: Option, + pub revoked_at: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct CreatePersonalAccessToken { + pub name: String, + pub scopes: TokenScopes, + pub expires_at: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct PersonalAccessTokenView { + pub id: String, + pub name: String, + pub token_prefix: String, + pub scopes: TokenScopes, + pub last_used_at: Option, + pub created_at: String, + pub expires_at: Option, + pub revoked_at: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct PersonalAccessTokenResponse { + #[serde(flatten)] + pub token_info: PersonalAccessTokenView, + pub token: String, } diff --git a/apps/backend/src/models/authorization.rs b/apps/backend/src/models/authorization.rs index cca8baf8..8e061792 100644 --- a/apps/backend/src/models/authorization.rs +++ b/apps/backend/src/models/authorization.rs @@ -93,6 +93,10 @@ pub enum Action { FilesWrite, WebhooksRead, WebhooksWrite, + WebhooksTrigger, + DeploymentsRead, + DeploymentsWrite, + DeploymentsTrigger, ApiKeysManage, MembersRead, MembersManage, @@ -142,18 +146,35 @@ impl Authorizer { /// Anything beyond that (site/schema/webhook/key/member management) is operator-only. pub const fn allows_site(role: SiteRole, action: Action) -> bool { match action { - Action::SiteRead - | Action::ContentRead - | Action::SchemaRead - | Action::FilesRead - | Action::WebhooksRead - | Action::MembersRead => true, - Action::ContentWrite | Action::FilesWrite => matches!(role, SiteRole::Editor), + Action::SiteRead | Action::ContentRead | Action::SchemaRead | Action::FilesRead | Action::WebhooksRead => { + true + } + Action::ContentWrite | Action::FilesWrite | Action::DeploymentsRead | Action::DeploymentsTrigger => { + matches!(role, SiteRole::Editor) + } _ => false, } } + pub const fn token_hard_denied(action: Action) -> bool { + matches!( + action, + Action::SiteDelete + | Action::ApiKeysManage + | Action::MembersRead + | Action::MembersManage + | Action::InstanceSettings + | Action::InstanceBackup + | Action::InstanceRestore + | Action::SiteBackup + | Action::SiteRestore + | Action::InstanceRolesGrant + ) + } pub const fn allows_api_key(can_write: bool, action: Action) -> bool { + if Self::token_hard_denied(action) { + return false; + } match action { Action::SiteRead | Action::ContentRead | Action::SchemaRead | Action::FilesRead | Action::WebhooksRead => { true diff --git a/apps/backend/src/models/deployment.rs b/apps/backend/src/models/deployment.rs new file mode 100644 index 00000000..581d3e8f --- /dev/null +++ b/apps/backend/src/models/deployment.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::collections::HashMap; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, FromRow, ToSchema)] +pub struct DeploymentTrigger { + pub id: String, + pub site_id: String, + pub label: String, + pub provider: String, + pub enabled: bool, + pub is_primary: bool, + pub cooldown_seconds: i64, + pub daily_quota: i64, + pub created_by: Option, + pub created_at: String, + pub updated_at: String, +} +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateDeploymentTrigger { + pub label: String, + pub provider: String, + pub url: String, + #[serde(default)] + pub headers: HashMap, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub is_primary: bool, + #[serde(default = "default_cooldown")] + pub cooldown_seconds: i64, + #[serde(default = "default_quota")] + pub daily_quota: i64, +} +fn default_true() -> bool { + true +} +fn default_cooldown() -> i64 { + 60 +} +fn default_quota() -> i64 { + 20 +} +#[derive(Debug, Clone, Serialize, FromRow, ToSchema)] +pub struct DeploymentJob { + pub id: String, + pub trigger_id: String, + pub site_id: String, + pub status: String, + pub status_code: Option, + pub error_category: Option, + pub response_body: Option, + pub retry_after_seconds: Option, + pub duration_ms: Option, + pub triggered_by: Option, + pub created_at: String, + pub started_at: Option, + pub finished_at: Option, +} diff --git a/apps/backend/src/models/site.rs b/apps/backend/src/models/site.rs index d12defd8..df0490ff 100644 --- a/apps/backend/src/models/site.rs +++ b/apps/backend/src/models/site.rs @@ -7,6 +7,8 @@ pub struct Site { pub id: String, pub name: String, pub storage_provider: String, + #[sqlx(default)] + pub storage_profile_id: Option, pub created_by: String, pub created_at: String, pub updated_at: String, @@ -17,6 +19,8 @@ pub struct SiteWithRole { pub id: String, pub name: String, pub storage_provider: String, + #[sqlx(default)] + pub storage_profile_id: Option, pub created_by: String, pub created_at: String, pub updated_at: String, @@ -26,7 +30,12 @@ pub struct SiteWithRole { #[derive(Deserialize, ToSchema)] pub struct CreateSite { pub name: String, + #[serde(default = "default_storage_kind")] pub storage_provider: String, + pub storage_profile_id: Option, +} +fn default_storage_kind() -> String { + "filesystem".into() } #[derive(Deserialize, ToSchema)] diff --git a/apps/backend/src/models/storage_profile.rs b/apps/backend/src/models/storage_profile.rs new file mode 100644 index 00000000..4bb726dd --- /dev/null +++ b/apps/backend/src/models/storage_profile.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use utoipa::ToSchema; +#[derive(Clone, Debug, Serialize, FromRow, ToSchema)] +pub struct StorageProfile { + pub id: String, + pub name: String, + pub kind: String, + pub endpoint: Option, + pub region: Option, + pub bucket: Option, + pub public_url: Option, + pub enabled: bool, + pub immutable: bool, + pub created_by: Option, + pub created_at: String, + pub updated_at: String, +} +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateStorageProfile { + pub name: String, + pub endpoint: String, + pub region: Option, + pub bucket: String, + pub public_url: Option, + pub access_key_id: String, + pub secret_access_key: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateStorageProfile { + pub name: String, + pub endpoint: String, + pub region: Option, + pub bucket: String, + pub public_url: Option, + pub enabled: bool, + pub access_key_id: Option, + pub secret_access_key: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct StorageProbeResult { + pub ok: bool, +} diff --git a/apps/backend/src/repository/mysql/access_token.rs b/apps/backend/src/repository/mysql/access_token.rs index 5df706f0..83acb073 100644 --- a/apps/backend/src/repository/mysql/access_token.rs +++ b/apps/backend/src/repository/mysql/access_token.rs @@ -1,9 +1,11 @@ use async_trait::async_trait; use sqlx::MySqlPool; -use crate::models::access_token::AccessToken; +use crate::models::access_token::{AccessToken, PersonalAccessToken}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository, NewAccessToken}; +use crate::repository::traits::{ + AccessTokenLookupRow, AccessTokenRepository, NewAccessToken, NewPersonalToken, PersonalTokenLookupRow, +}; pub struct MysqlAccessTokenRepository { pool: MySqlPool, @@ -19,7 +21,7 @@ impl MysqlAccessTokenRepository { impl AccessTokenRepository for MysqlAccessTokenRepository { async fn list(&self, site_id: &str) -> Result, RepositoryError> { let rows = sqlx::query_as::<_, AccessToken>( - "SELECT id, site_id, name, token_prefix, permission, created_by_user_id, CAST(last_used_at AS CHAR) AS last_used_at, CAST(created_at AS CHAR) AS created_at, CAST(expires_at AS CHAR) AS expires_at, CAST(revoked_at AS CHAR) AS revoked_at + "SELECT id, site_id, name, token_prefix, scopes_json AS permission, created_by_user_id, CAST(last_used_at AS CHAR) AS last_used_at, CAST(created_at AS CHAR) AS created_at, CAST(expires_at AS CHAR) AS expires_at, CAST(revoked_at AS CHAR) AS revoked_at FROM access_tokens WHERE site_id = ? ORDER BY created_at DESC", ) .bind(site_id) @@ -42,8 +44,8 @@ impl AccessTokenRepository for MysqlAccessTokenRepository { } = token; sqlx::query( "INSERT INTO access_tokens - (id, site_id, name, token_hash, token_prefix, token_hmac, permission, created_by_user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (id, site_id, name, token_hash, token_prefix, token_hmac, permission, scopes_json, created_by_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(id) .bind(site_id) @@ -51,6 +53,7 @@ impl AccessTokenRepository for MysqlAccessTokenRepository { .bind(token_hash) .bind(token_prefix) .bind(token_hmac) + .bind(if permission.contains(".write") { "write" } else { "read" }) .bind(permission) .bind(created_by_user_id) .execute(&self.pool) @@ -71,7 +74,7 @@ impl AccessTokenRepository for MysqlAccessTokenRepository { async fn find_by_prefix(&self, prefix: &str) -> Result, RepositoryError> { let rows = sqlx::query_as::<_, AccessTokenLookupRow>( - "SELECT id, site_id, token_hash, token_hmac, CAST(expires_at AS CHAR) AS expires_at, CAST(revoked_at AS CHAR) AS revoked_at, permission, CAST(last_used_at AS CHAR) AS last_used_at + "SELECT id, site_id, token_hash, token_hmac, CAST(expires_at AS CHAR) AS expires_at, CAST(revoked_at AS CHAR) AS revoked_at, scopes_json, CAST(last_used_at AS CHAR) AS last_used_at FROM access_tokens WHERE token_prefix = ?", ) .bind(prefix) @@ -88,4 +91,31 @@ impl AccessTokenRepository for MysqlAccessTokenRepository { .await; Ok(()) } + async fn list_personal(&self, user_id: &str) -> Result, RepositoryError> { + Ok(sqlx::query_as("SELECT id,user_id,name,token_prefix,scopes_json,CAST(last_used_at AS CHAR) AS last_used_at,CAST(created_at AS CHAR) AS created_at,CAST(expires_at AS CHAR) AS expires_at,CAST(revoked_at AS CHAR) AS revoked_at FROM personal_access_tokens WHERE user_id=? ORDER BY created_at DESC").bind(user_id).fetch_all(&self.pool).await?) + } + async fn create_personal(&self, t: NewPersonalToken<'_>) -> Result<(), RepositoryError> { + sqlx::query("INSERT INTO personal_access_tokens(id,user_id,name,token_hash,token_hmac,token_prefix,scopes_json,expires_at) VALUES(?,?,?,?,?,?,?,?)").bind(t.id).bind(t.user_id).bind(t.name).bind(t.token_hash).bind(t.token_hmac).bind(t.token_prefix).bind(t.scopes_json).bind(t.expires_at).execute(&self.pool).await?; + Ok(()) + } + async fn revoke_personal(&self, id: &str, user_id: &str) -> Result { + Ok(sqlx::query( + "UPDATE personal_access_tokens SET revoked_at=NOW() WHERE id=? AND user_id=? AND revoked_at IS NULL", + ) + .bind(id) + .bind(user_id) + .execute(&self.pool) + .await? + .rows_affected()) + } + async fn find_personal_by_prefix(&self, prefix: &str) -> Result, RepositoryError> { + Ok(sqlx::query_as("SELECT id,user_id,token_hash,CAST(expires_at AS CHAR) AS expires_at,CAST(revoked_at AS CHAR) AS revoked_at,scopes_json,CAST(last_used_at AS CHAR) AS last_used_at FROM personal_access_tokens WHERE token_prefix=?").bind(prefix).fetch_all(&self.pool).await?) + } + async fn touch_personal(&self, id: &str) -> Result<(), RepositoryError> { + sqlx::query("UPDATE personal_access_tokens SET last_used_at=NOW() WHERE id=?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } } diff --git a/apps/backend/src/repository/mysql/file.rs b/apps/backend/src/repository/mysql/file.rs index 47caa3f5..c5879c04 100644 --- a/apps/backend/src/repository/mysql/file.rs +++ b/apps/backend/src/repository/mysql/file.rs @@ -322,10 +322,11 @@ impl FileRepository for MysqlFileRepository { } async fn get_storage_provider(&self, site_id: &str) -> Result { - let provider: Option = sqlx::query_scalar("SELECT storage_provider FROM sites WHERE id = ?") - .bind(site_id) - .fetch_optional(&self.pool) - .await?; + let provider: Option = + sqlx::query_scalar("SELECT COALESCE(storage_profile_id, storage_provider) FROM sites WHERE id = ?") + .bind(site_id) + .fetch_optional(&self.pool) + .await?; Ok(provider.unwrap_or_else(|| "filesystem".into())) } diff --git a/apps/backend/src/repository/mysql/site.rs b/apps/backend/src/repository/mysql/site.rs index 61b8bffc..6b40e469 100644 --- a/apps/backend/src/repository/mysql/site.rs +++ b/apps/backend/src/repository/mysql/site.rs @@ -19,7 +19,7 @@ impl MysqlSiteRepository { impl SiteRepository for MysqlSiteRepository { async fn list_all(&self) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Site>( - "SELECT id, name, storage_provider, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM sites ORDER BY name", + "SELECT id, name, storage_provider, storage_profile_id, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM sites ORDER BY name", ) .fetch_all(&self.pool) .await?; @@ -29,7 +29,7 @@ impl SiteRepository for MysqlSiteRepository { async fn list_for_user(&self, user_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWithRole>( - "SELECT s.id, s.name, s.storage_provider, s.created_by, CAST(s.created_at AS CHAR) AS created_at, CAST(s.updated_at AS CHAR) AS updated_at, sm.role + "SELECT s.id, s.name, s.storage_provider, s.storage_profile_id, s.created_by, CAST(s.created_at AS CHAR) AS created_at, CAST(s.updated_at AS CHAR) AS updated_at, sm.role FROM sites s JOIN site_members sm ON s.id = sm.site_id WHERE sm.user_id = ? @@ -44,7 +44,7 @@ impl SiteRepository for MysqlSiteRepository { async fn get_by_id(&self, id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Site>( - "SELECT id, name, storage_provider, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM sites WHERE id = ?", + "SELECT id, name, storage_provider, storage_profile_id, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM sites WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/repository/postgres/access_token.rs b/apps/backend/src/repository/postgres/access_token.rs index 72409a6a..2e415f5a 100644 --- a/apps/backend/src/repository/postgres/access_token.rs +++ b/apps/backend/src/repository/postgres/access_token.rs @@ -1,9 +1,11 @@ use async_trait::async_trait; use sqlx::PgPool; -use crate::models::access_token::AccessToken; +use crate::models::access_token::{AccessToken, PersonalAccessToken}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository, NewAccessToken}; +use crate::repository::traits::{ + AccessTokenLookupRow, AccessTokenRepository, NewAccessToken, NewPersonalToken, PersonalTokenLookupRow, +}; pub struct PostgresAccessTokenRepository { pool: PgPool, @@ -19,7 +21,7 @@ impl PostgresAccessTokenRepository { impl AccessTokenRepository for PostgresAccessTokenRepository { async fn list(&self, site_id: &str) -> Result, RepositoryError> { let rows = sqlx::query_as::<_, AccessToken>( - "SELECT id, site_id, name, token_prefix, permission, created_by_user_id, last_used_at::text as last_used_at, created_at::text as created_at, expires_at::text as expires_at, revoked_at::text as revoked_at + "SELECT id, site_id, name, token_prefix, scopes_json AS permission, created_by_user_id, last_used_at::text as last_used_at, created_at::text as created_at, expires_at::text as expires_at, revoked_at::text as revoked_at FROM access_tokens WHERE site_id = $1 ORDER BY created_at DESC", ) .bind(site_id) @@ -42,8 +44,8 @@ impl AccessTokenRepository for PostgresAccessTokenRepository { } = token; sqlx::query( "INSERT INTO access_tokens - (id, site_id, name, token_hash, token_prefix, token_hmac, permission, created_by_user_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + (id, site_id, name, token_hash, token_prefix, token_hmac, permission, scopes_json, created_by_user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", ) .bind(id) .bind(site_id) @@ -51,6 +53,7 @@ impl AccessTokenRepository for PostgresAccessTokenRepository { .bind(token_hash) .bind(token_prefix) .bind(token_hmac) + .bind(if permission.contains(".write") { "write" } else { "read" }) .bind(permission) .bind(created_by_user_id) .execute(&self.pool) @@ -71,7 +74,7 @@ impl AccessTokenRepository for PostgresAccessTokenRepository { async fn find_by_prefix(&self, prefix: &str) -> Result, RepositoryError> { let rows = sqlx::query_as::<_, AccessTokenLookupRow>( - "SELECT id, site_id, token_hash, token_hmac, expires_at::text, revoked_at::text, permission, last_used_at::text + "SELECT id, site_id, token_hash, token_hmac, expires_at::text, revoked_at::text, scopes_json, last_used_at::text FROM access_tokens WHERE token_prefix = $1", ) .bind(prefix) @@ -88,4 +91,31 @@ impl AccessTokenRepository for PostgresAccessTokenRepository { .await; Ok(()) } + async fn list_personal(&self, user_id: &str) -> Result, RepositoryError> { + Ok(sqlx::query_as("SELECT id,user_id,name,token_prefix,scopes_json,last_used_at::text,created_at::text,expires_at::text,revoked_at::text FROM personal_access_tokens WHERE user_id=$1 ORDER BY created_at DESC").bind(user_id).fetch_all(&self.pool).await?) + } + async fn create_personal(&self, t: NewPersonalToken<'_>) -> Result<(), RepositoryError> { + sqlx::query("INSERT INTO personal_access_tokens(id,user_id,name,token_hash,token_hmac,token_prefix,scopes_json,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8::timestamptz)").bind(t.id).bind(t.user_id).bind(t.name).bind(t.token_hash).bind(t.token_hmac).bind(t.token_prefix).bind(t.scopes_json).bind(t.expires_at).execute(&self.pool).await?; + Ok(()) + } + async fn revoke_personal(&self, id: &str, user_id: &str) -> Result { + Ok(sqlx::query( + "UPDATE personal_access_tokens SET revoked_at=NOW() WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL", + ) + .bind(id) + .bind(user_id) + .execute(&self.pool) + .await? + .rows_affected()) + } + async fn find_personal_by_prefix(&self, prefix: &str) -> Result, RepositoryError> { + Ok(sqlx::query_as("SELECT id,user_id,token_hash,expires_at::text,revoked_at::text,scopes_json,last_used_at::text FROM personal_access_tokens WHERE token_prefix=$1").bind(prefix).fetch_all(&self.pool).await?) + } + async fn touch_personal(&self, id: &str) -> Result<(), RepositoryError> { + sqlx::query("UPDATE personal_access_tokens SET last_used_at=NOW() WHERE id=$1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } } diff --git a/apps/backend/src/repository/postgres/file.rs b/apps/backend/src/repository/postgres/file.rs index ecef75f5..e9fce24c 100644 --- a/apps/backend/src/repository/postgres/file.rs +++ b/apps/backend/src/repository/postgres/file.rs @@ -337,10 +337,11 @@ impl FileRepository for PostgresFileRepository { } async fn get_storage_provider(&self, site_id: &str) -> Result { - let provider: Option = sqlx::query_scalar("SELECT storage_provider FROM sites WHERE id = $1") - .bind(site_id) - .fetch_optional(&self.pool) - .await?; + let provider: Option = + sqlx::query_scalar("SELECT COALESCE(storage_profile_id, storage_provider) FROM sites WHERE id = $1") + .bind(site_id) + .fetch_optional(&self.pool) + .await?; Ok(provider.unwrap_or_else(|| "filesystem".into())) } diff --git a/apps/backend/src/repository/postgres/site.rs b/apps/backend/src/repository/postgres/site.rs index 23c9bf99..723ac1b8 100644 --- a/apps/backend/src/repository/postgres/site.rs +++ b/apps/backend/src/repository/postgres/site.rs @@ -19,7 +19,7 @@ impl PostgresSiteRepository { impl SiteRepository for PostgresSiteRepository { async fn list_all(&self) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Site>( - "SELECT id, name, storage_provider, created_by, created_at::text as created_at, updated_at::text as updated_at FROM sites ORDER BY name", + "SELECT id, name, storage_provider, storage_profile_id, created_by, created_at::text as created_at, updated_at::text as updated_at FROM sites ORDER BY name", ) .fetch_all(&self.pool) .await?; @@ -29,7 +29,7 @@ impl SiteRepository for PostgresSiteRepository { async fn list_for_user(&self, user_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWithRole>( - "SELECT s.id, s.name, s.storage_provider, s.created_by, s.created_at::text as created_at, s.updated_at::text as updated_at, sm.role + "SELECT s.id, s.name, s.storage_provider, s.storage_profile_id, s.created_by, s.created_at::text as created_at, s.updated_at::text as updated_at, sm.role FROM sites s JOIN site_members sm ON s.id = sm.site_id WHERE sm.user_id = $1 @@ -44,7 +44,7 @@ impl SiteRepository for PostgresSiteRepository { async fn get_by_id(&self, id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Site>( - "SELECT id, name, storage_provider, created_by, created_at::text as created_at, updated_at::text as updated_at FROM sites WHERE id = $1", + "SELECT id, name, storage_provider, storage_profile_id, created_by, created_at::text as created_at, updated_at::text as updated_at FROM sites WHERE id = $1", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/repository/sqlite/access_token.rs b/apps/backend/src/repository/sqlite/access_token.rs index 5164c0e8..deee21ce 100644 --- a/apps/backend/src/repository/sqlite/access_token.rs +++ b/apps/backend/src/repository/sqlite/access_token.rs @@ -1,9 +1,11 @@ use async_trait::async_trait; use sqlx::SqlitePool; -use crate::models::access_token::AccessToken; +use crate::models::access_token::{AccessToken, PersonalAccessToken}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository, NewAccessToken}; +use crate::repository::traits::{ + AccessTokenLookupRow, AccessTokenRepository, NewAccessToken, NewPersonalToken, PersonalTokenLookupRow, +}; pub struct SqliteAccessTokenRepository { pool: SqlitePool, @@ -19,7 +21,7 @@ impl SqliteAccessTokenRepository { impl AccessTokenRepository for SqliteAccessTokenRepository { async fn list(&self, site_id: &str) -> Result, RepositoryError> { let rows = sqlx::query_as::<_, AccessToken>( - "SELECT id, site_id, name, token_prefix, permission, created_by_user_id, last_used_at, created_at, expires_at, revoked_at + "SELECT id, site_id, name, token_prefix, scopes_json AS permission, created_by_user_id, last_used_at, created_at, expires_at, revoked_at FROM access_tokens WHERE site_id = ? ORDER BY created_at DESC", ) .bind(site_id) @@ -42,8 +44,8 @@ impl AccessTokenRepository for SqliteAccessTokenRepository { } = token; sqlx::query( "INSERT INTO access_tokens - (id, site_id, name, token_hash, token_prefix, token_hmac, permission, created_by_user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (id, site_id, name, token_hash, token_prefix, token_hmac, permission, scopes_json, created_by_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(id) .bind(site_id) @@ -51,6 +53,7 @@ impl AccessTokenRepository for SqliteAccessTokenRepository { .bind(token_hash) .bind(token_prefix) .bind(token_hmac) + .bind(if permission.contains(".write") { "write" } else { "read" }) .bind(permission) .bind(created_by_user_id) .execute(&self.pool) @@ -71,7 +74,7 @@ impl AccessTokenRepository for SqliteAccessTokenRepository { async fn find_by_prefix(&self, prefix: &str) -> Result, RepositoryError> { let rows = sqlx::query_as::<_, AccessTokenLookupRow>( - "SELECT id, site_id, token_hash, token_hmac, expires_at, revoked_at, permission, last_used_at + "SELECT id, site_id, token_hash, token_hmac, expires_at, revoked_at, scopes_json, last_used_at FROM access_tokens WHERE token_prefix = ?", ) .bind(prefix) @@ -88,4 +91,24 @@ impl AccessTokenRepository for SqliteAccessTokenRepository { .await; Ok(()) } + async fn list_personal(&self, user_id: &str) -> Result, RepositoryError> { + Ok(sqlx::query_as("SELECT id,user_id,name,token_prefix,scopes_json,last_used_at,created_at,expires_at,revoked_at FROM personal_access_tokens WHERE user_id=? ORDER BY created_at DESC").bind(user_id).fetch_all(&self.pool).await?) + } + async fn create_personal(&self, t: NewPersonalToken<'_>) -> Result<(), RepositoryError> { + sqlx::query("INSERT INTO personal_access_tokens(id,user_id,name,token_hash,token_hmac,token_prefix,scopes_json,expires_at) VALUES(?,?,?,?,?,?,?,?)").bind(t.id).bind(t.user_id).bind(t.name).bind(t.token_hash).bind(t.token_hmac).bind(t.token_prefix).bind(t.scopes_json).bind(t.expires_at).execute(&self.pool).await?; + Ok(()) + } + async fn revoke_personal(&self, id: &str, user_id: &str) -> Result { + Ok(sqlx::query("UPDATE personal_access_tokens SET revoked_at=datetime('now') WHERE id=? AND user_id=? AND revoked_at IS NULL").bind(id).bind(user_id).execute(&self.pool).await?.rows_affected()) + } + async fn find_personal_by_prefix(&self, prefix: &str) -> Result, RepositoryError> { + Ok(sqlx::query_as("SELECT id,user_id,token_hash,expires_at,revoked_at,scopes_json,last_used_at FROM personal_access_tokens WHERE token_prefix=?").bind(prefix).fetch_all(&self.pool).await?) + } + async fn touch_personal(&self, id: &str) -> Result<(), RepositoryError> { + sqlx::query("UPDATE personal_access_tokens SET last_used_at=datetime('now') WHERE id=?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } } diff --git a/apps/backend/src/repository/sqlite/file.rs b/apps/backend/src/repository/sqlite/file.rs index 542974ef..9eae9795 100644 --- a/apps/backend/src/repository/sqlite/file.rs +++ b/apps/backend/src/repository/sqlite/file.rs @@ -332,10 +332,11 @@ impl FileRepository for SqliteFileRepository { } async fn get_storage_provider(&self, site_id: &str) -> Result { - let provider: Option = sqlx::query_scalar("SELECT storage_provider FROM sites WHERE id = ?") - .bind(site_id) - .fetch_optional(&self.pool) - .await?; + let provider: Option = + sqlx::query_scalar("SELECT COALESCE(storage_profile_id, storage_provider) FROM sites WHERE id = ?") + .bind(site_id) + .fetch_optional(&self.pool) + .await?; Ok(provider.unwrap_or_else(|| "filesystem".into())) } diff --git a/apps/backend/src/repository/sqlite/site.rs b/apps/backend/src/repository/sqlite/site.rs index cfd22b47..02874713 100644 --- a/apps/backend/src/repository/sqlite/site.rs +++ b/apps/backend/src/repository/sqlite/site.rs @@ -19,7 +19,7 @@ impl SqliteSiteRepository { impl SiteRepository for SqliteSiteRepository { async fn list_all(&self) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Site>( - "SELECT id, name, storage_provider, created_by, created_at, updated_at FROM sites ORDER BY name", + "SELECT id, name, storage_provider, storage_profile_id, created_by, created_at, updated_at FROM sites ORDER BY name", ) .fetch_all(&self.pool) .await?; @@ -29,7 +29,7 @@ impl SiteRepository for SqliteSiteRepository { async fn list_for_user(&self, user_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWithRole>( - "SELECT s.id, s.name, s.storage_provider, s.created_by, s.created_at, s.updated_at, sm.role + "SELECT s.id, s.name, s.storage_provider, s.storage_profile_id, s.created_by, s.created_at, s.updated_at, sm.role FROM sites s JOIN site_members sm ON s.id = sm.site_id WHERE sm.user_id = ? @@ -44,7 +44,7 @@ impl SiteRepository for SqliteSiteRepository { async fn get_by_id(&self, id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Site>( - "SELECT id, name, storage_provider, created_by, created_at, updated_at FROM sites WHERE id = ?", + "SELECT id, name, storage_provider, storage_profile_id, created_by, created_at, updated_at FROM sites WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/repository/traits.rs b/apps/backend/src/repository/traits.rs index 7d9a6676..88b2256a 100644 --- a/apps/backend/src/repository/traits.rs +++ b/apps/backend/src/repository/traits.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use serde_json::Value; use crate::models::{ - access_token::AccessToken, + access_token::{AccessToken, PersonalAccessToken}, collection::Collection, entry::{Entry, EntryRevision}, file::{File, FileReference}, @@ -289,6 +289,27 @@ pub type AccessTokenLookupRow = ( Option, // last_used_at (for the touch debounce) ); +pub type PersonalTokenLookupRow = ( + String, + String, + String, + Option, + Option, + String, + Option, +); + +pub struct NewPersonalToken<'a> { + pub id: &'a str, + pub user_id: &'a str, + pub name: &'a str, + pub token_hash: &'a str, + pub token_hmac: &'a str, + pub token_prefix: &'a str, + pub scopes_json: &'a str, + pub expires_at: Option<&'a str>, +} + /// A new access token to persist (see [`AccessTokenRepository::create`]). pub struct NewAccessToken<'a> { pub id: &'a str, @@ -308,6 +329,11 @@ pub trait AccessTokenRepository: Send + Sync { async fn delete(&self, id: &str, site_id: &str) -> Result; async fn find_by_prefix(&self, prefix: &str) -> Result, RepositoryError>; async fn update_last_used(&self, id: &str) -> Result<(), RepositoryError>; + async fn list_personal(&self, user_id: &str) -> Result, RepositoryError>; + async fn create_personal(&self, token: NewPersonalToken<'_>) -> Result<(), RepositoryError>; + async fn revoke_personal(&self, id: &str, user_id: &str) -> Result; + async fn find_personal_by_prefix(&self, prefix: &str) -> Result, RepositoryError>; + async fn touch_personal(&self, id: &str) -> Result<(), RepositoryError>; } /// A webhook delivery record to insert (see [`WebhookRepository::create_delivery`]). diff --git a/apps/backend/src/router/access_tokens.rs b/apps/backend/src/router/access_tokens.rs index 568f0654..0b1a7663 100644 --- a/apps/backend/src/router/access_tokens.rs +++ b/apps/backend/src/router/access_tokens.rs @@ -3,6 +3,7 @@ use axum::{ routing::{delete, get, post}, }; +use crate::handlers::access_token_handler::{create_personal_token, list_personal_tokens, revoke_personal_token}; use crate::handlers::access_token_handler::{create_site_token, delete_site_token, list_site_tokens}; pub fn dashboard_routes() -> Router { @@ -11,3 +12,9 @@ pub fn dashboard_routes() -> Router { .route("/tokens", post(create_site_token)) .route("/tokens/{token_id}", delete(delete_site_token)) } + +pub fn account_routes() -> Router { + Router::new() + .route("/account/tokens", get(list_personal_tokens).post(create_personal_token)) + .route("/account/tokens/{token_id}", delete(revoke_personal_token)) +} diff --git a/apps/backend/src/router/deployments.rs b/apps/backend/src/router/deployments.rs new file mode 100644 index 00000000..6cf27c47 --- /dev/null +++ b/apps/backend/src/router/deployments.rs @@ -0,0 +1,18 @@ +use crate::handlers::deployment_handler; +use axum::{ + Router, + routing::{get, post}, +}; +pub fn routes() -> Router { + Router::new() + .route( + "/deployments", + get(deployment_handler::list).post(deployment_handler::create), + ) + .route( + "/deployments/{trigger_id}", + axum::routing::put(deployment_handler::update).delete(deployment_handler::delete), + ) + .route("/deployments/{trigger_id}/trigger", post(deployment_handler::trigger)) + .route("/deployments/{trigger_id}/history", get(deployment_handler::history)) +} diff --git a/apps/backend/src/router/graphql.rs b/apps/backend/src/router/graphql.rs index 45148950..c6a873f6 100644 --- a/apps/backend/src/router/graphql.rs +++ b/apps/backend/src/router/graphql.rs @@ -21,6 +21,7 @@ async fn graphql_handler( req: async_graphql_axum::GraphQLRequest, ) -> async_graphql_axum::GraphQLResponse { let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()); + let requested_site = headers.get("X-VCMS-Site").and_then(|v| v.to_str().ok()); // Per-request DataLoader (request-scoped cache) to batch nested resolvers. let entry_loader = async_graphql::dataloader::DataLoader::new( @@ -30,7 +31,14 @@ async fn graphql_handler( tokio::spawn, ); - let gql_ctx = GqlContext::from_request(repository, services, auth_header, &config.token_index_key).await; + let gql_ctx = GqlContext::from_request( + repository, + services, + auth_header, + requested_site, + &config.token_index_key, + ) + .await; let response = schema.execute(req.into_inner().data(gql_ctx).data(entry_loader)).await; async_graphql_axum::GraphQLResponse::from(response) diff --git a/apps/backend/src/router/instance.rs b/apps/backend/src/router/instance.rs index f1171b20..556489af 100644 --- a/apps/backend/src/router/instance.rs +++ b/apps/backend/src/router/instance.rs @@ -9,6 +9,7 @@ use crate::handlers::instance_handler::{ use crate::handlers::settings_handler::{ get_settings, update_backups, update_general, update_security, update_storage, }; +use crate::handlers::storage_profile_handler; pub fn routes() -> Router { Router::new() @@ -23,4 +24,16 @@ pub fn routes() -> Router { .route("/instance/settings/security", put(update_security)) .route("/instance/settings/storage", put(update_storage)) .route("/instance/settings/backups", put(update_backups)) + .route( + "/instance/storage-profiles", + get(storage_profile_handler::list).post(storage_profile_handler::create), + ) + .route( + "/instance/storage-profiles/{profile_id}", + axum::routing::put(storage_profile_handler::update).delete(storage_profile_handler::delete), + ) + .route( + "/storage-profiles/{id}/probe", + axum::routing::post(storage_profile_handler::probe), + ) } diff --git a/apps/backend/src/router/mod.rs b/apps/backend/src/router/mod.rs index dc9a759e..8f35ec52 100644 --- a/apps/backend/src/router/mod.rs +++ b/apps/backend/src/router/mod.rs @@ -3,6 +3,7 @@ mod auth; mod backup; mod collections; mod dashboard; +mod deployments; mod docs; mod entry; mod files; @@ -72,6 +73,7 @@ fn dashboard_site_v1_routes(max_upload_bytes: usize) -> Router { .merge(entry::dashboard_routes()) .merge(singleton::dashboard_routes()) .merge(webhooks::dashboard_routes()) + .merge(deployments::routes()) .merge(access_tokens::dashboard_routes()) .merge(backup::site_routes()) .merge(files::dashboard_routes(max_upload_bytes)); @@ -127,6 +129,7 @@ pub fn create_router( Router::new() .nest("/sites/{site_id}", dashboard_site_v1_routes(max_upload_bytes)) .merge(sites::dashboard_list_routes()) + .merge(access_tokens::account_routes()) .merge(instance::routes()) .merge(backup::instance_routes()) .layer(from_fn(dashboard_auth_middleware)), diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index dc7ba134..c36df065 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -70,6 +70,14 @@ pub async fn run( let mut services = Services::new(repository_arc.clone(), &pool, &config); services.auth = Arc::new((*services.auth).clone().with_settings(settings.clone())); services.webhook = Arc::new((*services.webhook).clone().with_settings(settings.clone())); + services + .storage_profile + .register_all(&storage_registry) + .await + .map_err(|error| format!("loading storage profiles: {error}"))?; + if let Err(error) = services.deployment.reconcile_interrupted().await { + tracing::error!(%error,"Failed to reconcile deployment jobs"); + } let backup_destination = crate::services::backup::build_backup_destination(&config) .map_err(|e| format!("Failed to initialize backup destination: {e}"))?; diff --git a/apps/backend/src/services/access_token.rs b/apps/backend/src/services/access_token.rs index c521dad1..e3879c8f 100644 --- a/apps/backend/src/services/access_token.rs +++ b/apps/backend/src/services/access_token.rs @@ -8,8 +8,11 @@ use tracing::{debug, error, info}; use uuid::Uuid; use crate::middleware::auth::compute_key_hmac; -use crate::models::access_token::{AccessToken, AccessTokenPermission, AccessTokenResponse}; -use crate::repository::traits::{AccessTokenRepository, NewAccessToken}; +use crate::models::access_token::{ + AccessToken, AccessTokenResponse, CreatePersonalAccessToken, PersonalAccessTokenResponse, PersonalAccessTokenView, + TokenScopes, decode_scopes, encode_scopes, +}; +use crate::repository::traits::{AccessTokenRepository, NewAccessToken, NewPersonalToken}; const SITE_TOKEN_PREFIX: &str = "vcms_site_"; @@ -62,6 +65,10 @@ impl AccessTokenService { format!("{}{}", SITE_TOKEN_PREFIX, random_chars) } + fn build_personal_token() -> String { + format!("vcms_pat_{}", Uuid::new_v4().simple()) + } + pub async fn list_site_tokens(&self, site_id: &str) -> Result, TokenError> { self.access_token_repo .list(site_id) @@ -73,10 +80,11 @@ impl AccessTokenService { &self, site_id: &str, name: String, - permission: AccessTokenPermission, + scopes: impl Into, created_by: Option<&str>, ) -> Result { - debug!("Creating site token: site_id={}, permission={}", site_id, permission); + debug!("Creating scoped site token: site_id={}", site_id); + let scopes = scopes.into(); let name = name.trim(); if name.is_empty() { @@ -88,7 +96,7 @@ impl AccessTokenService { let token_hash = hash(&raw_token, self.bcrypt_cost).map_err(|e| TokenError::HashError(e.to_string()))?; let token_hmac = compute_key_hmac(&raw_token, &self.hmac_secret); let id = Uuid::now_v7().to_string(); - let permission_str = permission.as_str(); + let permission_str = encode_scopes(&scopes).map_err(|e| TokenError::DatabaseError(e.to_string()))?; self.access_token_repo .create(NewAccessToken { @@ -98,7 +106,7 @@ impl AccessTokenService { token_hash: &token_hash, token_prefix: &prefix, token_hmac: &token_hmac, - permission: permission_str, + permission: &permission_str, created_by_user_id: created_by, }) .await @@ -113,11 +121,83 @@ impl AccessTokenService { name: name.to_string(), token: raw_token, token_prefix: prefix, - permission: permission_str.to_string(), + permission: permission_str, + scopes, created_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), }) } + pub async fn list_personal_tokens(&self, user_id: &str) -> Result, TokenError> { + self.access_token_repo + .list_personal(user_id) + .await + .map_err(|e| TokenError::DatabaseError(e.to_string()))? + .into_iter() + .map(|t| { + Ok(PersonalAccessTokenView { + id: t.id, + name: t.name, + token_prefix: t.token_prefix, + scopes: decode_scopes(&t.scopes_json).map_err(|e| TokenError::DatabaseError(e.to_string()))?, + last_used_at: t.last_used_at, + created_at: t.created_at, + expires_at: t.expires_at, + revoked_at: t.revoked_at, + }) + }) + .collect() + } + + pub async fn create_personal_token( + &self, + user_id: &str, + payload: CreatePersonalAccessToken, + ) -> Result { + let name = payload.name.trim(); + if name.is_empty() { + return Err(TokenError::NameRequired); + } + let raw = Self::build_personal_token(); + let prefix: String = raw.chars().take(24).collect(); + let id = Uuid::now_v7().to_string(); + let token_hash = hash(&raw, self.bcrypt_cost).map_err(|e| TokenError::HashError(e.to_string()))?; + let token_hmac = compute_key_hmac(&raw, &self.hmac_secret); + let scopes_json = encode_scopes(&payload.scopes).map_err(|e| TokenError::DatabaseError(e.to_string()))?; + self.access_token_repo + .create_personal(NewPersonalToken { + id: &id, + user_id, + name, + token_hash: &token_hash, + token_hmac: &token_hmac, + token_prefix: &prefix, + scopes_json: &scopes_json, + expires_at: payload.expires_at.as_deref(), + }) + .await + .map_err(|e| TokenError::DatabaseError(e.to_string()))?; + Ok(PersonalAccessTokenResponse { + token_info: PersonalAccessTokenView { + id, + name: name.into(), + token_prefix: prefix, + scopes: payload.scopes, + last_used_at: None, + created_at: chrono::Utc::now().to_rfc3339(), + expires_at: payload.expires_at, + revoked_at: None, + }, + token: raw, + }) + } + + pub async fn revoke_personal_token(&self, id: &str, user_id: &str) -> Result { + self.access_token_repo + .revoke_personal(id, user_id) + .await + .map_err(|e| TokenError::DatabaseError(e.to_string())) + } + pub async fn delete_site_token(&self, token_id: &str, site_id: &str) -> Result { debug!("Deleting site token: token_id={}, site_id={}", token_id, site_id); @@ -143,6 +223,7 @@ impl AccessTokenService { #[cfg(test)] mod tests { use super::*; + use crate::models::access_token::AccessTokenPermission; use crate::test_helpers::InMemoryAccessTokenRepository; fn test_repo() -> Arc { @@ -165,7 +246,11 @@ mod tests { let response = result.unwrap(); assert_eq!(response.name, "Test Token"); assert_eq!(response.site_id, "site-123"); - assert_eq!(response.permission, "read"); + assert!( + response + .scopes + .contains(&crate::models::access_token::TokenScope::ContentRead) + ); assert!(response.token.starts_with(SITE_TOKEN_PREFIX)); } diff --git a/apps/backend/src/services/authorization.rs b/apps/backend/src/services/authorization.rs index a7a9f91f..cfbce1c2 100644 --- a/apps/backend/src/services/authorization.rs +++ b/apps/backend/src/services/authorization.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::middleware::auth::Actor; +use crate::middleware::auth::{Actor, scope_for_action}; use crate::models::authorization::{Action, Authorizer, InstanceRole, SiteRole}; use crate::repository::traits::UserRepository; use crate::services::error::ServiceError; @@ -21,12 +21,22 @@ impl AuthorizationService { if site_id != k.site_id { return Err(ServiceError::Forbidden("Token is not authorized for this site".into())); } - if Authorizer::allows_api_key(k.permission.can_write(), action) { + if !Authorizer::token_hard_denied(action) + && scope_for_action(action).is_some_and(|s| k.scopes.contains(&s)) + { Ok(()) } else { Err(ServiceError::InsufficientPermission("write".into())) } } + Actor::PersonalToken(token) => { + if Authorizer::token_hard_denied(action) + || !scope_for_action(action).is_some_and(|s| token.scopes.contains(&s)) + { + return Err(ServiceError::InsufficientPermission("token scope".into())); + } + self.check_site_access(&token.user_id, site_id, action).await + } Actor::User(user) => self.check_site_access(&user.user_id, site_id, action).await, } } @@ -95,6 +105,7 @@ mod tests { token_id: "token-1".to_string(), site_id: "site-1".to_string(), permission: AccessTokenPermission::Read, + scopes: AccessTokenPermission::Read.into(), }); let result = checker.require_site_action(&actor, "site-1", Action::ContentRead).await; @@ -109,6 +120,7 @@ mod tests { token_id: "token-1".to_string(), site_id: "site-1".to_string(), permission: AccessTokenPermission::Read, + scopes: AccessTokenPermission::Read.into(), }); let result = checker @@ -125,6 +137,7 @@ mod tests { token_id: "token-1".to_string(), site_id: "site-1".to_string(), permission: AccessTokenPermission::Read, + scopes: AccessTokenPermission::Read.into(), }); let result = checker.require_site_action(&actor, "site-2", Action::ContentRead).await; @@ -139,6 +152,7 @@ mod tests { token_id: "token-1".to_string(), site_id: "site-1".to_string(), permission: AccessTokenPermission::Write, + scopes: AccessTokenPermission::Write.into(), }); let result = checker @@ -175,6 +189,7 @@ mod tests { token_id: "token-1".to_string(), site_id: "site-1".to_string(), permission: AccessTokenPermission::Read, + scopes: AccessTokenPermission::Read.into(), }); assert!(checker.actor_user_id(&api_actor).is_none()); } @@ -187,6 +202,7 @@ mod tests { token_id: "token-1".to_string(), site_id: "site-1".to_string(), permission: AccessTokenPermission::Read, + scopes: AccessTokenPermission::Read.into(), }); assert_eq!(checker.actor_site_id(&api_actor), Some("site-1")); diff --git a/apps/backend/src/services/backup/meta.rs b/apps/backend/src/services/backup/meta.rs index 4ebb5f10..0a136143 100644 --- a/apps/backend/src/services/backup/meta.rs +++ b/apps/backend/src/services/backup/meta.rs @@ -90,6 +90,7 @@ pub struct BackupRow { pub started_at: Option, pub completed_at: Option, pub created_at: String, + pub storage_profile_id: Option, } /// Frontend-friendly view with proper booleans. @@ -110,6 +111,7 @@ pub struct BackupInfo { pub created_by: Option, pub completed_at: Option, pub created_at: String, + pub storage_profile_id: Option, } impl From for BackupInfo { @@ -130,13 +132,14 @@ impl From for BackupInfo { created_by: r.created_by, completed_at: r.completed_at, created_at: r.created_at, + storage_profile_id: r.storage_profile_id, } } } const BACKUP_COLS: &str = "id, schedule_id, scope, site_id, status, format_version, schema_version, \ size_bytes, file_count, includes_files, encrypted, destination_key, checksum, error, created_by, \ - started_at, completed_at, created_at"; + started_at, completed_at, created_at, storage_profile_id"; /// Insert a `running` backup row at the start of a run. #[allow(clippy::too_many_arguments)] @@ -149,12 +152,13 @@ pub async fn insert_running( includes_files: bool, encrypt: bool, created_by: Option<&str>, + storage_profile_id: Option<&str>, now: &str, ) -> Result<(), BackupError> { exec!( pool, - "INSERT INTO backups (id, schedule_id, scope, site_id, status, format_version, includes_files, encrypted, created_by, started_at, created_at) \ - VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?)", + "INSERT INTO backups (id, schedule_id, scope, site_id, status, format_version, includes_files, encrypted, created_by, started_at, created_at, storage_profile_id) \ + VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?)", id.to_string(), schedule_id.map(|s| s.to_string()), scope.to_string(), @@ -164,6 +168,7 @@ pub async fn insert_running( i64::from(encrypt), created_by.map(|s| s.to_string()), now.to_string(), + storage_profile_id.map(|value| value.to_string()), now.to_string(), ); Ok(()) @@ -307,6 +312,7 @@ pub struct BackupScheduleRow { pub created_by: Option, pub created_at: String, pub updated_at: String, + pub storage_profile_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -322,6 +328,7 @@ pub struct ScheduleInfo { pub last_run_at: Option, pub next_run_at: Option, pub created_at: String, + pub storage_profile_id: Option, } impl From for ScheduleInfo { @@ -338,12 +345,13 @@ impl From for ScheduleInfo { last_run_at: r.last_run_at, next_run_at: r.next_run_at, created_at: r.created_at, + storage_profile_id: r.storage_profile_id, } } } const SCHEDULE_COLS: &str = "id, scope, site_id, cron, retention_n, include_files, encrypt, enabled, \ - last_run_at, next_run_at, created_by, created_at, updated_at"; + last_run_at, next_run_at, created_by, created_at, updated_at, storage_profile_id"; #[allow(clippy::too_many_arguments)] pub async fn create_schedule( @@ -358,12 +366,13 @@ pub async fn create_schedule( enabled: bool, next_run_at: Option<&str>, created_by: Option<&str>, + storage_profile_id: Option<&str>, now: &str, ) -> Result<(), BackupError> { exec!( pool, - "INSERT INTO backup_schedules (id, scope, site_id, cron, retention_n, include_files, encrypt, enabled, next_run_at, created_by, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO backup_schedules (id, scope, site_id, cron, retention_n, include_files, encrypt, enabled, next_run_at, created_by, created_at, updated_at, storage_profile_id) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", id.to_string(), scope.to_string(), site_id.map(|s| s.to_string()), @@ -375,6 +384,7 @@ pub async fn create_schedule( next_run_at.map(|s| s.to_string()), created_by.map(|s| s.to_string()), now.to_string(), + storage_profile_id.map(|value| value.to_string()), now.to_string(), ); Ok(()) @@ -390,17 +400,19 @@ pub async fn update_schedule( encrypt: bool, enabled: bool, next_run_at: Option<&str>, + storage_profile_id: Option<&str>, now: &str, ) -> Result<(), BackupError> { exec!( pool, - "UPDATE backup_schedules SET cron = ?, retention_n = ?, include_files = ?, encrypt = ?, enabled = ?, next_run_at = ?, updated_at = ? WHERE id = ?", + "UPDATE backup_schedules SET cron = ?, retention_n = ?, include_files = ?, encrypt = ?, enabled = ?, next_run_at = ?, storage_profile_id = ?, updated_at = ? WHERE id = ?", cron.to_string(), retention_n, i64::from(include_files), i64::from(encrypt), i64::from(enabled), next_run_at.map(|s| s.to_string()), + storage_profile_id.map(|value| value.to_string()), now.to_string(), id.to_string(), ); diff --git a/apps/backend/src/services/backup/mod.rs b/apps/backend/src/services/backup/mod.rs index 84f07e12..3907129d 100644 --- a/apps/backend/src/services/backup/mod.rs +++ b/apps/backend/src/services/backup/mod.rs @@ -124,6 +124,7 @@ pub struct CreateBackupOptions { pub encrypt: bool, pub schedule_id: Option, pub created_by: Option, + pub storage_profile_id: Option, } /// Where a restore reads its artifact from. @@ -174,6 +175,9 @@ pub struct BackupService { #[derive(Clone, Serialize, Deserialize)] #[serde(tag = "provider", rename_all = "lowercase")] enum DestinationSpec { + Profile { + id: String, + }, Filesystem { path: String, }, @@ -351,6 +355,7 @@ impl BackupService { opts.include_files, opts.encrypt, opts.created_by.as_deref(), + opts.storage_profile_id.as_deref(), &now, ) .await?; @@ -398,7 +403,17 @@ impl BackupService { Scope::Instance => format!("instance/{id}.cmsbak"), Scope::Site(sid) => format!("site/{sid}/{id}.cmsbak"), }; - let destination = self.destination_snapshot(); + let destination = if let Some(profile_id) = &opts.storage_profile_id { + BackupDestination { + provider: self + .storage + .get(profile_id) + .ok_or_else(|| BackupError::Invalid("storage profile is unavailable".into()))?, + spec: DestinationSpec::Profile { id: profile_id.clone() }, + } + } else { + self.destination_snapshot() + }; destination .provider .put(&key, bytes::Bytes::from(bytes.clone()), "application/octet-stream") @@ -577,6 +592,10 @@ impl BackupService { let (spec, key): (DestinationSpec, String) = serde_json::from_slice(&plaintext).map_err(|error| BackupError::Invalid(error.to_string()))?; let provider: Arc = match spec { + DestinationSpec::Profile { id } => self + .storage + .get(&id) + .ok_or_else(|| BackupError::Invalid("storage profile is unavailable".into()))?, DestinationSpec::Filesystem { path } => { Arc::new(FileSystemStorage::new(&path).map_err(|error| BackupError::Storage(error.to_string()))?) } diff --git a/apps/backend/src/services/backup/scheduler.rs b/apps/backend/src/services/backup/scheduler.rs index 96bdfedb..fb3018fe 100644 --- a/apps/backend/src/services/backup/scheduler.rs +++ b/apps/backend/src/services/backup/scheduler.rs @@ -50,6 +50,7 @@ async fn tick(service: &BackupService) -> Result<(), BackupError> { encrypt: sched.encrypt != 0, schedule_id: Some(sched.id.clone()), created_by: sched.created_by.clone(), + storage_profile_id: sched.storage_profile_id.clone(), }; if let Err(e) = service.create_backup(opts).await { tracing::error!(schedule = %sched.id, error = %e, "scheduled backup failed"); diff --git a/apps/backend/src/services/deployment.rs b/apps/backend/src/services/deployment.rs new file mode 100644 index 00000000..420aa9b7 --- /dev/null +++ b/apps/backend/src/services/deployment.rs @@ -0,0 +1,398 @@ +use std::{sync::Arc, time::Instant}; + +use crate::{ + database::pool::DbPool, + models::deployment::{CreateDeploymentTrigger, DeploymentJob, DeploymentTrigger}, + services::webhook::WebhookService, +}; +use uuid::Uuid; + +fn map_job_insert_error(error: sqlx::Error) -> String { + if error + .as_database_error() + .is_some_and(|database_error| database_error.is_unique_violation()) + { + "deployment_in_progress".into() + } else { + error.to_string() + } +} + +#[cfg(test)] +mod tests { + use crate::database::{init_db, pool::DbPool}; + + #[tokio::test] + async fn active_job_is_unique_per_trigger() { + let pool = init_db("sqlite::memory:").await.expect("database initializes"); + let DbPool::Sqlite(database) = pool else { unreachable!() }; + sqlx::query("INSERT INTO users(id,name,email,password_hash) VALUES('u','User','u@example.com','hash')") + .execute(&database) + .await + .expect("user inserts"); + sqlx::query("INSERT INTO sites(id,name,storage_provider,created_by,storage_profile_id) VALUES('s','Site','filesystem','u','local-filesystem')") + .execute(&database).await.expect("site inserts"); + sqlx::query("INSERT INTO deployment_triggers(id,site_id,label,provider,url_encrypted,headers_encrypted) VALUES('t','s','Deploy','custom','u','h')") + .execute(&database).await.expect("trigger inserts"); + sqlx::query("INSERT INTO deployment_jobs(id,trigger_id,site_id,status) VALUES('j1','t','s','queued')") + .execute(&database) + .await + .expect("first active job inserts"); + let error = + sqlx::query("INSERT INTO deployment_jobs(id,trigger_id,site_id,status) VALUES('j2','t','s','running')") + .execute(&database) + .await + .expect_err("second active job must conflict"); + assert!( + error + .as_database_error() + .is_some_and(|value| value.is_unique_violation()) + ); + sqlx::query("UPDATE deployment_jobs SET status='succeeded' WHERE id='j1'") + .execute(&database) + .await + .expect("job completes"); + sqlx::query("INSERT INTO deployment_jobs(id,trigger_id,site_id,status) VALUES('j2','t','s','queued')") + .execute(&database) + .await + .expect("new active job inserts after completion"); + } +} + +#[derive(Clone)] +pub struct DeploymentService { + pool: DbPool, + webhooks: Arc, +} + +impl DeploymentService { + pub fn new(pool: DbPool, webhooks: Arc) -> Self { + Self { pool, webhooks } + } + pub async fn reconcile_interrupted(&self) -> Result { + match &self.pool{DbPool::Sqlite(p)=>sqlx::query("UPDATE deployment_jobs SET status='failed',error_category='interrupted',finished_at=datetime('now') WHERE status IN ('queued','running')").execute(p).await.map(|v|v.rows_affected()).map_err(|e|e.to_string()),DbPool::Postgres(p)=>sqlx::query("UPDATE deployment_jobs SET status='failed',error_category='interrupted',finished_at=NOW() WHERE status IN ('queued','running')").execute(p).await.map(|v|v.rows_affected()).map_err(|e|e.to_string()),DbPool::MySql(p)=>sqlx::query("UPDATE deployment_jobs SET status='failed',error_category='interrupted',finished_at=NOW() WHERE status IN ('queued','running')").execute(p).await.map(|v|v.rows_affected()).map_err(|e|e.to_string())} + } + + pub async fn list(&self, site_id: &str) -> Result, String> { + match &self.pool { + DbPool::Sqlite(p) => sqlx::query_as("SELECT id,site_id,label,provider,enabled,is_primary,cooldown_seconds,daily_quota,created_by,created_at,updated_at FROM deployment_triggers WHERE site_id=? ORDER BY is_primary DESC,label").bind(site_id).fetch_all(p).await, + DbPool::Postgres(p) => sqlx::query_as("SELECT id,site_id,label,provider,enabled,is_primary,cooldown_seconds,daily_quota,created_by,created_at::text,updated_at::text FROM deployment_triggers WHERE site_id=$1 ORDER BY is_primary DESC,label").bind(site_id).fetch_all(p).await, + DbPool::MySql(p) => sqlx::query_as("SELECT id,site_id,label,provider,enabled,is_primary,cooldown_seconds,daily_quota,created_by,CAST(created_at AS CHAR) AS created_at,CAST(updated_at AS CHAR) AS updated_at FROM deployment_triggers WHERE site_id=? ORDER BY is_primary DESC,label").bind(site_id).fetch_all(p).await, + }.map_err(|e| e.to_string()) + } + + pub async fn create( + &self, + site_id: &str, + user_id: &str, + value: CreateDeploymentTrigger, + ) -> Result { + if value.label.trim().is_empty() + || !matches!( + value.provider.as_str(), + "cloudflare" | "vercel" | "netlify" | "github" | "custom" + ) + || value.cooldown_seconds < 0 + || value.daily_quota < 0 + { + return Err("invalid_deployment_trigger".into()); + } + let (url, headers) = self + .webhooks + .protect_deployment_config(&value.url, &value.headers) + .map_err(|e| e.to_string())?; + if value.is_primary { + self.clear_primary(site_id).await?; + } + let id = Uuid::now_v7().to_string(); + match &self.pool { + DbPool::Sqlite(p) => sqlx::query("INSERT INTO deployment_triggers(id,site_id,label,provider,url_encrypted,headers_encrypted,enabled,is_primary,cooldown_seconds,daily_quota,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)").bind(&id).bind(site_id).bind(value.label.trim()).bind(&value.provider).bind(&url).bind(&headers).bind(value.enabled).bind(value.is_primary).bind(value.cooldown_seconds).bind(value.daily_quota).bind(user_id).execute(p).await.map(|_|()).map_err(|e|e.to_string()), + DbPool::Postgres(p) => sqlx::query("INSERT INTO deployment_triggers(id,site_id,label,provider,url_encrypted,headers_encrypted,enabled,is_primary,cooldown_seconds,daily_quota,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)").bind(&id).bind(site_id).bind(value.label.trim()).bind(&value.provider).bind(&url).bind(&headers).bind(value.enabled).bind(value.is_primary).bind(value.cooldown_seconds).bind(value.daily_quota).bind(user_id).execute(p).await.map(|_|()).map_err(|e|e.to_string()), + DbPool::MySql(p) => sqlx::query("INSERT INTO deployment_triggers(id,site_id,label,provider,url_encrypted,headers_encrypted,enabled,is_primary,cooldown_seconds,daily_quota,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)").bind(&id).bind(site_id).bind(value.label.trim()).bind(&value.provider).bind(&url).bind(&headers).bind(value.enabled).bind(value.is_primary).bind(value.cooldown_seconds).bind(value.daily_quota).bind(user_id).execute(p).await.map(|_|()).map_err(|e|e.to_string()), + }?; + self.list(site_id) + .await? + .into_iter() + .find(|v| v.id == id) + .ok_or_else(|| "trigger_not_found".into()) + } + + pub async fn update( + &self, + site_id: &str, + id: &str, + value: CreateDeploymentTrigger, + ) -> Result { + if value.label.trim().is_empty() + || !matches!( + value.provider.as_str(), + "cloudflare" | "vercel" | "netlify" | "github" | "custom" + ) + || value.cooldown_seconds < 0 + || value.daily_quota < 0 + { + return Err("invalid_deployment_trigger".into()); + } + if !self.list(site_id).await?.iter().any(|trigger| trigger.id == id) { + return Err("trigger_not_found".into()); + } + let (url, headers) = self + .webhooks + .protect_deployment_config(&value.url, &value.headers) + .map_err(|error| error.to_string())?; + if value.is_primary { + self.clear_primary(site_id).await?; + } + match &self.pool { + DbPool::Sqlite(pool) => sqlx::query("UPDATE deployment_triggers SET label=?,provider=?,url_encrypted=?,headers_encrypted=?,enabled=?,is_primary=?,cooldown_seconds=?,daily_quota=?,updated_at=datetime('now') WHERE id=? AND site_id=?") + .bind(value.label.trim()).bind(value.provider).bind(url).bind(headers).bind(value.enabled).bind(value.is_primary).bind(value.cooldown_seconds).bind(value.daily_quota).bind(id).bind(site_id).execute(pool).await.map(|_| ()), + DbPool::Postgres(pool) => sqlx::query("UPDATE deployment_triggers SET label=$1,provider=$2,url_encrypted=$3,headers_encrypted=$4,enabled=$5,is_primary=$6,cooldown_seconds=$7,daily_quota=$8,updated_at=NOW() WHERE id=$9 AND site_id=$10") + .bind(value.label.trim()).bind(value.provider).bind(url).bind(headers).bind(value.enabled).bind(value.is_primary).bind(value.cooldown_seconds).bind(value.daily_quota).bind(id).bind(site_id).execute(pool).await.map(|_| ()), + DbPool::MySql(pool) => sqlx::query("UPDATE deployment_triggers SET label=?,provider=?,url_encrypted=?,headers_encrypted=?,enabled=?,is_primary=?,cooldown_seconds=?,daily_quota=?,updated_at=NOW() WHERE id=? AND site_id=?") + .bind(value.label.trim()).bind(value.provider).bind(url).bind(headers).bind(value.enabled).bind(value.is_primary).bind(value.cooldown_seconds).bind(value.daily_quota).bind(id).bind(site_id).execute(pool).await.map(|_| ()), + } + .map_err(|error| error.to_string())?; + self.list(site_id) + .await? + .into_iter() + .find(|trigger| trigger.id == id) + .ok_or_else(|| "trigger_not_found".into()) + } + + pub async fn delete(&self, site_id: &str, id: &str) -> Result { + match &self.pool { + DbPool::Sqlite(pool) => sqlx::query("DELETE FROM deployment_triggers WHERE id=? AND site_id=?") + .bind(id) + .bind(site_id) + .execute(pool) + .await + .map(|value| value.rows_affected()) + .map_err(|error| error.to_string()), + DbPool::Postgres(pool) => sqlx::query("DELETE FROM deployment_triggers WHERE id=$1 AND site_id=$2") + .bind(id) + .bind(site_id) + .execute(pool) + .await + .map(|value| value.rows_affected()) + .map_err(|error| error.to_string()), + DbPool::MySql(pool) => sqlx::query("DELETE FROM deployment_triggers WHERE id=? AND site_id=?") + .bind(id) + .bind(site_id) + .execute(pool) + .await + .map(|value| value.rows_affected()) + .map_err(|error| error.to_string()), + } + } + + async fn clear_primary(&self, site_id: &str) -> Result<(), String> { + match &self.pool { + DbPool::Sqlite(p) => sqlx::query("UPDATE deployment_triggers SET is_primary=0 WHERE site_id=?") + .bind(site_id) + .execute(p) + .await + .map(|_| ()), + DbPool::Postgres(p) => sqlx::query("UPDATE deployment_triggers SET is_primary=FALSE WHERE site_id=$1") + .bind(site_id) + .execute(p) + .await + .map(|_| ()), + DbPool::MySql(p) => sqlx::query("UPDATE deployment_triggers SET is_primary=FALSE WHERE site_id=?") + .bind(site_id) + .execute(p) + .await + .map(|_| ()), + } + .map_err(|e| e.to_string()) + } + + pub async fn history(&self, trigger_id: &str) -> Result, String> { + match &self.pool { + DbPool::Sqlite(p) => sqlx::query_as("SELECT id,trigger_id,site_id,status,status_code,error_category,response_body,retry_after_seconds,duration_ms,triggered_by,created_at,started_at,finished_at FROM deployment_jobs WHERE trigger_id=? ORDER BY created_at DESC LIMIT 100").bind(trigger_id).fetch_all(p).await, + DbPool::Postgres(p) => sqlx::query_as("SELECT id,trigger_id,site_id,status,status_code,error_category,response_body,retry_after_seconds,duration_ms,triggered_by,created_at::text,started_at::text,finished_at::text FROM deployment_jobs WHERE trigger_id=$1 ORDER BY created_at DESC LIMIT 100").bind(trigger_id).fetch_all(p).await, + DbPool::MySql(p) => sqlx::query_as("SELECT id,trigger_id,site_id,status,status_code,error_category,response_body,retry_after_seconds,duration_ms,triggered_by,CAST(created_at AS CHAR) AS created_at,CAST(started_at AS CHAR) AS started_at,CAST(finished_at AS CHAR) AS finished_at FROM deployment_jobs WHERE trigger_id=? ORDER BY created_at DESC LIMIT 100").bind(trigger_id).fetch_all(p).await, + }.map_err(|e|e.to_string()) + } + + pub async fn trigger( + self: &Arc, + site_id: &str, + trigger_id: &str, + user_id: &str, + ) -> Result { + let trigger = self + .list(site_id) + .await? + .into_iter() + .find(|v| v.id == trigger_id) + .ok_or("trigger_not_found")?; + if !trigger.enabled { + return Err("trigger_disabled".into()); + } + let history = self.history(trigger_id).await?; + if history + .iter() + .any(|j| matches!(j.status.as_str(), "queued" | "running")) + { + return Err("deployment_in_progress".into()); + } + let now = chrono::Utc::now(); + let age_seconds = |j: &DeploymentJob| { + crate::middleware::auth::parse_db_timestamp(&j.created_at).map(|d| (now - d).num_seconds()) + }; + if trigger.cooldown_seconds > 0 + && history + .first() + .and_then(age_seconds) + .is_some_and(|age| age < trigger.cooldown_seconds) + { + return Err(format!( + "deployment_cooldown:{}", + trigger.cooldown_seconds - history.first().and_then(age_seconds).unwrap_or(0) + )); + } + if trigger.daily_quota > 0 + && history + .iter() + .filter(|j| age_seconds(j).is_some_and(|age| age < 86_400)) + .count() as i64 + >= trigger.daily_quota + { + return Err("deployment_daily_quota".into()); + } + let job = self.insert_job(site_id, trigger_id, user_id).await?; + let service = self.clone(); + let job_id = job.id.clone(); + tokio::spawn(async move { + service.run_job(trigger, job_id).await; + }); + Ok(job) + } + + async fn insert_job(&self, site_id: &str, trigger_id: &str, user_id: &str) -> Result { + let id = Uuid::now_v7().to_string(); + match &self.pool { + DbPool::Sqlite(p) => sqlx::query( + "INSERT INTO deployment_jobs(id,trigger_id,site_id,status,triggered_by) VALUES(?,?,?,'queued',?)", + ) + .bind(&id) + .bind(trigger_id) + .bind(site_id) + .bind(user_id) + .execute(p) + .await + .map(|_| ()) + .map_err(map_job_insert_error), + DbPool::Postgres(p) => sqlx::query( + "INSERT INTO deployment_jobs(id,trigger_id,site_id,status,triggered_by) VALUES($1,$2,$3,'queued',$4)", + ) + .bind(&id) + .bind(trigger_id) + .bind(site_id) + .bind(user_id) + .execute(p) + .await + .map(|_| ()) + .map_err(map_job_insert_error), + DbPool::MySql(p) => sqlx::query( + "INSERT INTO deployment_jobs(id,trigger_id,site_id,status,triggered_by) VALUES(?,?,?,'queued',?)", + ) + .bind(&id) + .bind(trigger_id) + .bind(site_id) + .bind(user_id) + .execute(p) + .await + .map(|_| ()) + .map_err(map_job_insert_error), + }?; + self.history(trigger_id) + .await? + .into_iter() + .find(|j| j.id == id) + .ok_or("job_not_found".into()) + } + + async fn run_job(&self, trigger: DeploymentTrigger, job_id: String) { + let started = Instant::now(); + let result = self.load_secret(&trigger.id).await.and_then(|(u, h)| { + self.webhooks + .reveal_deployment_config(&u, &h) + .map_err(|e| e.to_string()) + }); + let result = match result { + Ok((url, headers)) => match self.webhooks.build_protected_client(&url).await { + Ok(client) => { + let mut request = client.post(&url); + for (key, value) in headers { + request = request.header(key, value); + } + request.send().await.map_err(|error| error.to_string()).map(|response| { + ( + response.status().as_u16() as i32, + response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()), + ) + }) + } + Err(error) => Err(error.to_string()), + }, + Err(e) => Err(e), + }; + let (status, code, category, retry) = match result { + Ok((c, r)) if (200..300).contains(&c) => ("succeeded", Some(c), None, r), + Ok((429, r)) => ("failed", Some(429), Some("provider_rate_limit"), r), + Ok((c, r)) if c >= 500 => ("failed", Some(c), Some("provider_server"), r), + Ok((c, r)) => ("failed", Some(c), Some("configuration"), r), + Err(_) => ("failed", None, Some("network"), None), + }; + let _ = self + .finish( + &job_id, + status, + code, + category, + retry, + started.elapsed().as_millis() as i64, + ) + .await; + } + + async fn load_secret(&self, id: &str) -> Result<(String, String), String> { + match &self.pool { + DbPool::Sqlite(p) => { + sqlx::query_as("SELECT url_encrypted,headers_encrypted FROM deployment_triggers WHERE id=?") + .bind(id) + .fetch_one(p) + .await + } + DbPool::Postgres(p) => { + sqlx::query_as("SELECT url_encrypted,headers_encrypted FROM deployment_triggers WHERE id=$1") + .bind(id) + .fetch_one(p) + .await + } + DbPool::MySql(p) => { + sqlx::query_as("SELECT url_encrypted,headers_encrypted FROM deployment_triggers WHERE id=?") + .bind(id) + .fetch_one(p) + .await + } + } + .map_err(|e| e.to_string()) + } + async fn finish( + &self, + id: &str, + status: &str, + code: Option, + category: Option<&str>, + retry: Option, + duration: i64, + ) -> Result<(), String> { + match &self.pool{DbPool::Sqlite(p)=>sqlx::query("UPDATE deployment_jobs SET status=?,status_code=?,error_category=?,retry_after_seconds=?,duration_ms=?,started_at=COALESCE(started_at,created_at),finished_at=datetime('now') WHERE id=?").bind(status).bind(code).bind(category).bind(retry).bind(duration).bind(id).execute(p).await.map(|_|()).map_err(|e|e.to_string()),DbPool::Postgres(p)=>sqlx::query("UPDATE deployment_jobs SET status=$1,status_code=$2,error_category=$3,retry_after_seconds=$4,duration_ms=$5,started_at=COALESCE(started_at,created_at),finished_at=NOW() WHERE id=$6").bind(status).bind(code).bind(category).bind(retry).bind(duration).bind(id).execute(p).await.map(|_|()).map_err(|e|e.to_string()),DbPool::MySql(p)=>sqlx::query("UPDATE deployment_jobs SET status=?,status_code=?,error_category=?,retry_after_seconds=?,duration_ms=?,started_at=COALESCE(started_at,created_at),finished_at=NOW() WHERE id=?").bind(status).bind(code).bind(category).bind(retry).bind(duration).bind(id).execute(p).await.map(|_|()).map_err(|e|e.to_string())} + } +} diff --git a/apps/backend/src/services/mod.rs b/apps/backend/src/services/mod.rs index 50e79fb3..76bdb856 100644 --- a/apps/backend/src/services/mod.rs +++ b/apps/backend/src/services/mod.rs @@ -4,6 +4,7 @@ pub mod authorization; pub mod backup; pub mod collection; pub mod definition_validation; +pub mod deployment; pub mod entry; pub mod error; pub mod file; @@ -11,6 +12,7 @@ pub mod search; pub mod settings; pub mod singleton; pub mod site; +pub mod storage_profile; pub mod webhook; use std::path::PathBuf; @@ -32,6 +34,8 @@ pub struct Services { pub file: Arc, pub singleton: Arc, pub webhook: Arc, + pub deployment: Arc, + pub storage_profile: Arc, /// Full-text search engine used for **reads** (ranked queries). `None` when /// search is disabled or the index couldn't be opened — callers then fall back /// to the SQL `LIKE` path. Read-write in the server process, read-only in @@ -73,6 +77,11 @@ impl Services { None }; + let webhook = Arc::new(webhook::WebhookService::new( + repository.webhook.clone(), + &config.webhook_encryption_key, + config.webhook_allow_private_targets, + )); Self { auth: Arc::new(auth::AuthService::new( repository.user.clone(), @@ -111,10 +120,11 @@ impl Services { ) .with_queue(search_queue.clone()), ), - webhook: Arc::new(webhook::WebhookService::new( - repository.webhook.clone(), + webhook: webhook.clone(), + deployment: Arc::new(deployment::DeploymentService::new(pool.clone(), webhook)), + storage_profile: Arc::new(storage_profile::StorageProfileService::new( + pool.clone(), &config.webhook_encryption_key, - config.webhook_allow_private_targets, )), search, search_queue, diff --git a/apps/backend/src/services/site.rs b/apps/backend/src/services/site.rs index 4bc6bb4b..ae6bf7a7 100644 --- a/apps/backend/src/services/site.rs +++ b/apps/backend/src/services/site.rs @@ -83,9 +83,8 @@ impl SiteService { pub async fn list_sites_for_actor(&self, actor: &Actor) -> Result, SiteError> { match actor { Actor::User(user) => self.list_sites_for_user(&user.user_id).await, - Actor::ApiKey(_) => { - unreachable!("ApiKey should not be used for listing sites") - } + Actor::ApiKey(key) => Ok(self.get_site(&key.site_id).await?.into_iter().map(|site| serde_json::json!({"id":site.id,"name":site.name,"storage_provider":site.storage_provider,"storage_profile_id":site.storage_profile_id,"created_by":site.created_by,"created_at":site.created_at,"updated_at":site.updated_at,"role":"site_key"})).collect()), + Actor::PersonalToken(token) => self.list_sites_for_user(&token.user_id).await, } } @@ -113,6 +112,7 @@ impl SiteService { "id": site.id, "name": site.name, "storage_provider": site.storage_provider, + "storage_profile_id": site.storage_profile_id, "created_by": site.created_by, "created_at": site.created_at, "updated_at": site.updated_at, @@ -412,6 +412,7 @@ mod tests { id: "site-123".to_string(), name: "Test Site".to_string(), storage_provider: "filesystem".to_string(), + storage_profile_id: Some("local-filesystem".to_string()), created_by: "user-123".to_string(), created_at: "2024-01-01 00:00:00".to_string(), updated_at: "2024-01-01 00:00:00".to_string(), diff --git a/apps/backend/src/services/storage_profile.rs b/apps/backend/src/services/storage_profile.rs new file mode 100644 index 00000000..269e8f9c --- /dev/null +++ b/apps/backend/src/services/storage_profile.rs @@ -0,0 +1,300 @@ +use crate::{ + database::pool::DbPool, + models::storage_profile::{CreateStorageProfile, StorageProfile, UpdateStorageProfile}, + storage::{S3Storage, StorageProvider, StorageRegistry}, +}; +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit}, +}; +use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use rand::Rng; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone)] +pub struct StorageProfileService { + pool: DbPool, + key: [u8; 32], +} +impl StorageProfileService { + pub fn new(pool: DbPool, secret: &str) -> Self { + Self { + pool, + key: Sha256::digest(secret.as_bytes()).into(), + } + } + pub async fn list(&self) -> Result, String> { + match &self.pool{ + DbPool::Sqlite(p)=>sqlx::query_as("SELECT id,name,kind,endpoint,region,bucket,public_url,enabled,immutable,created_by,created_at,updated_at FROM storage_profiles ORDER BY immutable DESC,name").fetch_all(p).await, + DbPool::Postgres(p)=>sqlx::query_as("SELECT id,name,kind,endpoint,region,bucket,public_url,enabled,immutable,created_by,created_at::text,updated_at::text FROM storage_profiles ORDER BY immutable DESC,name").fetch_all(p).await, + DbPool::MySql(p)=>sqlx::query_as("SELECT id,name,kind,endpoint,region,bucket,public_url,enabled,immutable,created_by,CAST(created_at AS CHAR) AS created_at,CAST(updated_at AS CHAR) AS updated_at FROM storage_profiles ORDER BY immutable DESC,name").fetch_all(p).await, + }.map_err(|e|e.to_string()) + } + pub async fn create(&self, v: CreateStorageProfile, by: &str) -> Result { + if v.name.trim().is_empty() || v.bucket.trim().is_empty() { + return Err("name_and_bucket_required".into()); + } + url::Url::parse(&v.endpoint).map_err(|_| "invalid_endpoint")?; + let id = Uuid::now_v7().to_string(); + let credentials = self.encrypt( + &serde_json::json!({"access_key_id":v.access_key_id,"secret_access_key":v.secret_access_key}).to_string(), + )?; + match &self.pool{ + DbPool::Sqlite(p)=>sqlx::query("INSERT INTO storage_profiles(id,name,kind,endpoint,region,bucket,public_url,credentials_encrypted,created_by)VALUES(?,?,'s3',?,?,?,?,?,?)").bind(&id).bind(v.name.trim()).bind(v.endpoint).bind(v.region).bind(v.bucket).bind(v.public_url).bind(credentials).bind(by).execute(p).await.map(|_|()).map_err(|e|e.to_string()), + DbPool::Postgres(p)=>sqlx::query("INSERT INTO storage_profiles(id,name,kind,endpoint,region,bucket,public_url,credentials_encrypted,created_by)VALUES($1,$2,'s3',$3,$4,$5,$6,$7,$8)").bind(&id).bind(v.name.trim()).bind(v.endpoint).bind(v.region).bind(v.bucket).bind(v.public_url).bind(credentials).bind(by).execute(p).await.map(|_|()).map_err(|e|e.to_string()), + DbPool::MySql(p)=>sqlx::query("INSERT INTO storage_profiles(id,name,kind,endpoint,region,bucket,public_url,credentials_encrypted,created_by)VALUES(?,?,'s3',?,?,?,?,?,?)").bind(&id).bind(v.name.trim()).bind(v.endpoint).bind(v.region).bind(v.bucket).bind(v.public_url).bind(credentials).bind(by).execute(p).await.map(|_|()).map_err(|e|e.to_string()), + }?; + self.list() + .await? + .into_iter() + .find(|p| p.id == id) + .ok_or("profile_not_found".into()) + } + pub async fn update(&self, id: &str, v: UpdateStorageProfile) -> Result { + let current = self + .list() + .await? + .into_iter() + .find(|profile| profile.id == id) + .ok_or("profile_not_found")?; + if current.immutable { + return Err("immutable_profile".into()); + } + if !v.enabled && self.reference_count(id).await? > 0 { + return Err("profile_in_use".into()); + } + if v.name.trim().is_empty() || v.bucket.trim().is_empty() { + return Err("name_and_bucket_required".into()); + } + url::Url::parse(&v.endpoint).map_err(|_| "invalid_endpoint")?; + let credentials = match (v.access_key_id, v.secret_access_key) { + (Some(access), Some(secret)) if !access.is_empty() && !secret.is_empty() => { + Some(self.encrypt(&serde_json::json!({"access_key_id":access,"secret_access_key":secret}).to_string())?) + } + (None, None) => None, + _ => return Err("both_credentials_required".into()), + }; + match &self.pool { + DbPool::Sqlite(pool) => sqlx::query("UPDATE storage_profiles SET name=?,endpoint=?,region=?,bucket=?,public_url=?,enabled=?,credentials_encrypted=COALESCE(?,credentials_encrypted),updated_at=datetime('now') WHERE id=?") + .bind(v.name.trim()).bind(v.endpoint).bind(v.region).bind(v.bucket).bind(v.public_url).bind(v.enabled).bind(credentials).bind(id).execute(pool).await.map(|_| ()), + DbPool::Postgres(pool) => sqlx::query("UPDATE storage_profiles SET name=$1,endpoint=$2,region=$3,bucket=$4,public_url=$5,enabled=$6,credentials_encrypted=COALESCE($7,credentials_encrypted),updated_at=NOW() WHERE id=$8") + .bind(v.name.trim()).bind(v.endpoint).bind(v.region).bind(v.bucket).bind(v.public_url).bind(v.enabled).bind(credentials).bind(id).execute(pool).await.map(|_| ()), + DbPool::MySql(pool) => sqlx::query("UPDATE storage_profiles SET name=?,endpoint=?,region=?,bucket=?,public_url=?,enabled=?,credentials_encrypted=COALESCE(?,credentials_encrypted),updated_at=NOW() WHERE id=?") + .bind(v.name.trim()).bind(v.endpoint).bind(v.region).bind(v.bucket).bind(v.public_url).bind(v.enabled).bind(credentials).bind(id).execute(pool).await.map(|_| ()), + }.map_err(|error| error.to_string())?; + self.list() + .await? + .into_iter() + .find(|profile| profile.id == id) + .ok_or_else(|| "profile_not_found".into()) + } + + pub async fn probe(&self, id: &str) -> Result<(), String> { + let profile = self + .list() + .await? + .into_iter() + .find(|profile| profile.id == id) + .ok_or("profile_not_found")?; + if profile.kind == "filesystem" { + return Ok(()); + } + let provider = self.build_provider(&profile).await?; + let key = format!("vcms-probes/{}.txt", Uuid::now_v7()); + provider + .put(&key, bytes::Bytes::from_static(b"vcms storage probe"), "text/plain") + .await + .map_err(|error| error.to_string())?; + provider.delete(&key).await.map_err(|error| error.to_string()) + } + async fn reference_count(&self, id: &str) -> Result { + let count: i64 = match &self.pool { + DbPool::Sqlite(p) => { + sqlx::query_scalar("SELECT (SELECT COUNT(*) FROM sites WHERE storage_profile_id=?) + (SELECT COUNT(*) FROM backup_schedules WHERE storage_profile_id=?) + (SELECT COUNT(*) FROM backups WHERE storage_profile_id=? AND status IN ('pending','running'))") + .bind(id) + .bind(id) + .bind(id) + .fetch_one(p) + .await + } + DbPool::Postgres(p) => { + sqlx::query_scalar("SELECT (SELECT COUNT(*) FROM sites WHERE storage_profile_id=$1) + (SELECT COUNT(*) FROM backup_schedules WHERE storage_profile_id=$1) + (SELECT COUNT(*) FROM backups WHERE storage_profile_id=$1 AND status IN ('pending','running'))") + .bind(id) + .fetch_one(p) + .await + } + DbPool::MySql(p) => { + sqlx::query_scalar("SELECT (SELECT COUNT(*) FROM sites WHERE storage_profile_id=?) + (SELECT COUNT(*) FROM backup_schedules WHERE storage_profile_id=?) + (SELECT COUNT(*) FROM backups WHERE storage_profile_id=? AND status IN ('pending','running'))") + .bind(id) + .bind(id) + .bind(id) + .fetch_one(p) + .await + } + }.map_err(|error| error.to_string())?; + Ok(count) + } + pub async fn delete(&self, id: &str) -> Result<(), String> { + let profile = self + .list() + .await? + .into_iter() + .find(|profile| profile.id == id) + .ok_or("profile_not_found")?; + if profile.immutable { + return Err("immutable_profile".into()); + } + if self.reference_count(id).await? > 0 { + return Err("profile_in_use".into()); + } + match &self.pool { + DbPool::Sqlite(p) => sqlx::query("DELETE FROM storage_profiles WHERE id=?") + .bind(id) + .execute(p) + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + DbPool::Postgres(p) => sqlx::query("DELETE FROM storage_profiles WHERE id=$1") + .bind(id) + .execute(p) + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + DbPool::MySql(p) => sqlx::query("DELETE FROM storage_profiles WHERE id=?") + .bind(id) + .execute(p) + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + }?; + Ok(()) + } + pub async fn assign_site(&self, site: &str, profile: &str) -> Result<(), String> { + let p = self + .list() + .await? + .into_iter() + .find(|v| v.id == profile && v.enabled) + .ok_or("storage_profile_not_found")?; + match &self.pool { + DbPool::Sqlite(db) => sqlx::query("UPDATE sites SET storage_profile_id=?,storage_provider=? WHERE id=?") + .bind(profile) + .bind(&p.kind) + .bind(site) + .execute(db) + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + DbPool::Postgres(db) => { + sqlx::query("UPDATE sites SET storage_profile_id=$1,storage_provider=$2 WHERE id=$3") + .bind(profile) + .bind(&p.kind) + .bind(site) + .execute(db) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + } + DbPool::MySql(db) => sqlx::query("UPDATE sites SET storage_profile_id=?,storage_provider=? WHERE id=?") + .bind(profile) + .bind(&p.kind) + .bind(site) + .execute(db) + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + }?; + Ok(()) + } + pub async fn register_all(&self, registry: &StorageRegistry) -> Result<(), String> { + if let Some(local) = registry.get("filesystem") { + registry.register("local-filesystem", local); + } + for profile in self + .list() + .await? + .into_iter() + .filter(|profile| profile.kind == "s3" && profile.enabled) + { + registry.register(&profile.id, self.build_provider(&profile).await?); + } + Ok(()) + } + async fn build_provider(&self, profile: &StorageProfile) -> Result, String> { + let credentials = self.credentials(&profile.id).await?; + let access = credentials + .get("access_key_id") + .and_then(|value| value.as_str()) + .ok_or("invalid_storage_credentials")?; + let secret = credentials + .get("secret_access_key") + .and_then(|value| value.as_str()) + .ok_or("invalid_storage_credentials")?; + Ok(Arc::new( + S3Storage::new( + access, + secret, + profile.bucket.as_deref().ok_or("missing_bucket")?, + profile.region.as_deref().unwrap_or("auto"), + profile.endpoint.as_deref(), + profile.public_url.as_deref(), + ) + .map_err(|error| error.to_string())?, + )) + } + async fn credentials(&self, id: &str) -> Result { + let encrypted: Option = match &self.pool { + DbPool::Sqlite(pool) => { + sqlx::query_scalar("SELECT credentials_encrypted FROM storage_profiles WHERE id=?") + .bind(id) + .fetch_optional(pool) + .await + } + DbPool::Postgres(pool) => { + sqlx::query_scalar("SELECT credentials_encrypted FROM storage_profiles WHERE id=$1") + .bind(id) + .fetch_optional(pool) + .await + } + DbPool::MySql(pool) => { + sqlx::query_scalar("SELECT credentials_encrypted FROM storage_profiles WHERE id=?") + .bind(id) + .fetch_optional(pool) + .await + } + } + .map_err(|error| error.to_string())? + .flatten(); + serde_json::from_str(&self.decrypt(encrypted.as_deref().ok_or("missing_storage_credentials")?)?) + .map_err(|error| error.to_string()) + } + fn decrypt(&self, value: &str) -> Result { + let data = B64 + .decode(value.strip_prefix("v1:").ok_or("invalid_envelope")?) + .map_err(|error| error.to_string())?; + if data.len() < 13 { + return Err("invalid_envelope".into()); + } + let nonce: [u8; 12] = data[..12].try_into().map_err(|_| "invalid_nonce")?; + let cipher = Aes256Gcm::new_from_slice(&self.key).map_err(|error| error.to_string())?; + let plain = cipher + .decrypt(&Nonce::from(nonce), &data[12..]) + .map_err(|_| "decryption_failed")?; + String::from_utf8(plain).map_err(|error| error.to_string()) + } + fn encrypt(&self, text: &str) -> Result { + let cipher = Aes256Gcm::new_from_slice(&self.key).map_err(|e| e.to_string())?; + let mut nonce = [0u8; 12]; + rand::rng().fill_bytes(&mut nonce); + let mut out = nonce.to_vec(); + let nonce_value = Nonce::from(nonce); + out.extend( + cipher + .encrypt(&nonce_value, text.as_bytes()) + .map_err(|_| "encryption_failed")?, + ); + Ok(format!("v1:{}", B64.encode(out))) + } +} diff --git a/apps/backend/src/services/webhook.rs b/apps/backend/src/services/webhook.rs index 48aa5a22..8653a6f6 100644 --- a/apps/backend/src/services/webhook.rs +++ b/apps/backend/src/services/webhook.rs @@ -101,6 +101,45 @@ impl WebhookService { self } + pub(crate) fn protect_deployment_config( + &self, + url: &str, + headers: &HashMap, + ) -> Result<(String, String), WebhookError> { + validate_url(url, self.allow_private_targets())?; + Ok(( + encrypt_headers(&HashMap::from([("url".into(), url.into())]), &self.encryption_key), + encrypt_headers(headers, &self.encryption_key), + )) + } + + pub(crate) fn reveal_deployment_config( + &self, + url: &str, + headers: &str, + ) -> Result<(String, HashMap), WebhookError> { + let url = decrypt_headers_checked(url, &self.encryption_key) + .and_then(|mut values| values.remove("url")) + .ok_or_else(|| WebhookError::DatabaseError("Deployment URL cannot be decrypted".into()))?; + let headers = decrypt_headers_checked(headers, &self.encryption_key) + .ok_or_else(|| WebhookError::DatabaseError("Deployment headers cannot be decrypted".into()))?; + validate_url(&url, self.allow_private_targets())?; + Ok((url, headers)) + } + + pub(crate) async fn build_protected_client(&self, url: &str) -> Result { + validate_url(url, self.allow_private_targets())?; + let parsed_url = url::Url::parse(url).map_err(|_| WebhookError::InvalidUrl("Invalid URL format".into()))?; + let safe_addr = resolve_safe_addr(&parsed_url, self.allow_private_targets()).await?; + let host = parsed_url.host_str().unwrap_or_default().to_string(); + reqwest::Client::builder() + .timeout(Duration::from_secs(WEBHOOK_TIMEOUT_SECS)) + .redirect(reqwest::redirect::Policy::none()) + .resolve(&host, safe_addr) + .build() + .map_err(|error| WebhookError::DeliveryFailed(error.to_string())) + } + fn allow_private_targets(&self) -> bool { self.settings .as_ref() @@ -294,21 +333,10 @@ impl WebhookService { "Webhook is disabled and needs reconfiguration".into(), )); } - validate_url(&webhook.url, self.allow_private_targets())?; - let parsed_url = - url::Url::parse(&webhook.url).map_err(|_| WebhookError::InvalidUrl("Invalid URL format".into()))?; - let safe_addr = resolve_safe_addr(&parsed_url, self.allow_private_targets()).await?; - let host = parsed_url.host_str().unwrap_or_default().to_string(); - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(WEBHOOK_TIMEOUT_SECS)) - .redirect(reqwest::redirect::Policy::none()) - .resolve(&host, safe_addr) - .build() - .map_err(|e| { - error!("Failed to create HTTP client for webhook: error={}", e); - WebhookError::DeliveryFailed(e.to_string()) - })?; + let client = self.build_protected_client(&webhook.url).await.map_err(|error| { + error!("Failed to create HTTP client for webhook: error={}", error); + error + })?; let mut request = client.post(&webhook.url); debug!( diff --git a/apps/backend/src/test_helpers.rs b/apps/backend/src/test_helpers.rs index 63adb064..0309b3fb 100644 --- a/apps/backend/src/test_helpers.rs +++ b/apps/backend/src/test_helpers.rs @@ -333,6 +333,7 @@ impl SiteRepository for InMemorySiteRepository { id: id.to_string(), name: name.to_string(), storage_provider: storage_provider.to_string(), + storage_profile_id: None, created_by: created_by.to_string(), created_at: now_timestamp(), updated_at: now_timestamp(), @@ -1193,6 +1194,30 @@ impl AccessTokenRepository for InMemoryAccessTokenRepository { async fn update_last_used(&self, _id: &str) -> Result<(), RepositoryError> { Ok(()) } + async fn list_personal( + &self, + _user_id: &str, + ) -> Result, RepositoryError> { + Ok(vec![]) + } + async fn create_personal( + &self, + _token: crate::repository::traits::NewPersonalToken<'_>, + ) -> Result<(), RepositoryError> { + Ok(()) + } + async fn revoke_personal(&self, _id: &str, _user_id: &str) -> Result { + Ok(0) + } + async fn find_personal_by_prefix( + &self, + _prefix: &str, + ) -> Result, RepositoryError> { + Ok(vec![]) + } + async fn touch_personal(&self, _id: &str) -> Result<(), RepositoryError> { + Ok(()) + } } #[derive(Clone)] diff --git a/apps/dashboard/src/components/account/personal-tokens-card.tsx b/apps/dashboard/src/components/account/personal-tokens-card.tsx new file mode 100644 index 00000000..4edaa848 --- /dev/null +++ b/apps/dashboard/src/components/account/personal-tokens-card.tsx @@ -0,0 +1,204 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Copy, KeyRound, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Field, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + createPersonalToken, + getPersonalTokens, + revokePersonalToken, +} from "@/lib/api"; + +const SCOPES = [ + "site.read", + "site.settings.read", + "content.read", + "content.write", + "files.read", + "files.write", + "schema.read", + "schema.write", + "webhooks.read", + "webhooks.write", + "webhooks.trigger", + "deployments.read", + "deployments.write", + "deployments.trigger", + "mcp.use", +]; + +export function PersonalTokensCard() { + const client = useQueryClient(); + const [name, setName] = useState(""); + const [scopes, setScopes] = useState([ + "site.read", + "content.read", + "content.write", + "files.read", + "files.write", + "schema.read", + "deployments.read", + "deployments.trigger", + "mcp.use", + ]); + const [secret, setSecret] = useState(null); + const { data = [] } = useQuery({ + queryKey: ["personal-tokens"], + queryFn: getPersonalTokens, + }); + const create = useMutation({ + mutationFn: () => createPersonalToken({ name, scopes, expires_at: null }), + onSuccess: (value) => { + setSecret(value.token); + setName(""); + client.invalidateQueries({ queryKey: ["personal-tokens"] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + const revoke = useMutation({ + mutationFn: revokePersonalToken, + onSuccess: () => + client.invalidateQueries({ queryKey: ["personal-tokens"] }), + onError: (e: Error) => toast.error(e.message), + }); + return ( + <> + + + Personal access tokens + + User-owned credentials for MCP, CLI, and API access. Effective + access always remains limited by your current role. + + + + + + Token name + setName(e.target.value)} + placeholder="Claude Desktop" + /> + +
+ Capabilities +
+ {SCOPES.map((scope) => ( + + + setScopes((current) => + checked + ? [...current, scope] + : current.filter((item) => item !== scope), + ) + } + /> + {scope} + + ))} +
+
+
+ +
+ {data.map((token) => ( +
+
+

{token.name}

+

+ {token.token_prefix}… +

+
+ {token.scopes.map((scope) => ( + + {scope} + + ))} +
+
+ +
+ ))} +
+
+
+ { + if (!open) setSecret(null); + }} + > + + + Copy personal access token + + This secret is shown once. Store it securely. + + +
+ + +
+ + + +
+
+ + ); +} diff --git a/apps/dashboard/src/components/backups/backups-section.tsx b/apps/dashboard/src/components/backups/backups-section.tsx index 3452e4c4..4ef5153e 100644 --- a/apps/dashboard/src/components/backups/backups-section.tsx +++ b/apps/dashboard/src/components/backups/backups-section.tsx @@ -60,6 +60,7 @@ import { createBackupSchedule, deleteBackup, deleteBackupSchedule, + getStorageProfiles, type InspectResult, inspectBackup, inspectBackupUpload, @@ -112,6 +113,7 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { const [includeFiles, setIncludeFiles] = useState(true); const [encrypt, setEncrypt] = useState(false); + const [storageProfileId, setStorageProfileId] = useState("local-filesystem"); const [restoreSource, setRestoreSource] = useState( null, @@ -144,6 +146,10 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { queryKey: ["backup-schedules", scopeKey], queryFn: () => listBackupSchedules(scope), }); + const storageProfilesQuery = useQuery({ + queryKey: ["storage-profiles"], + queryFn: getStorageProfiles, + }); const invalidateBackups = () => queryClient.invalidateQueries({ queryKey: ["backups", scopeKey] }); @@ -152,7 +158,11 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { const createMutation = useMutation({ mutationFn: () => - createBackup(scope, { include_files: includeFiles, encrypt }), + createBackup(scope, { + include_files: includeFiles, + encrypt, + storage_profile_id: storageProfileId, + }), onSuccess: () => { invalidateBackups(); toast.success("Backup created"); @@ -290,6 +300,28 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { + + Destination + +
@@ -425,6 +457,7 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { { await createBackupSchedule(scope, input); @@ -437,6 +470,7 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { include_files: s.include_files, encrypt: s.encrypt, enabled: !s.enabled, + storage_profile_id: s.storage_profile_id ?? undefined, }); invalidateSchedules(); }} @@ -666,6 +700,7 @@ interface SchedulesCardProps { onToggle: (s: import("@/lib/api").BackupSchedule) => Promise; onRun: (id: string) => Promise; onDelete: (id: string) => Promise; + storageProfiles: import("@/lib/api").StorageProfile[]; } function SchedulesCard({ @@ -675,6 +710,7 @@ function SchedulesCard({ onToggle, onRun, onDelete, + storageProfiles, }: SchedulesCardProps) { const [preset, setPreset] = useState(CRON_PRESETS[0].value); const [customCron, setCustomCron] = useState("0 2 * * *"); @@ -682,6 +718,7 @@ function SchedulesCard({ const [includeFiles, setIncludeFiles] = useState(true); const [encrypt, setEncrypt] = useState(false); const [submitting, setSubmitting] = useState(false); + const [storageProfileId, setStorageProfileId] = useState("local-filesystem"); const cron = preset === "custom" ? customCron : preset; @@ -694,6 +731,7 @@ function SchedulesCard({ include_files: includeFiles, encrypt, enabled: true, + storage_profile_id: storageProfileId, }); toast.success("Schedule added"); } catch (e) { @@ -712,7 +750,29 @@ function SchedulesCard({ -
+
+ + Destination + +

- {field.state.value === "s3" - ? "Files will be stored in your S3 bucket" - : "Files will be stored on the local filesystem"} + Storage cannot be changed after site creation.

); diff --git a/apps/dashboard/src/routes/_admin/_shell/settings.tsx b/apps/dashboard/src/routes/_admin/_shell/settings.tsx index 7cbd6f8d..2f1c3aad 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings.tsx @@ -72,7 +72,7 @@ function InstanceSettingsLayout() { Security )} - {isOwner && ( + {isOperator(me?.instance_role) && ( { const me = await context.queryClient.ensureQueryData({ queryKey: ["me"], queryFn: getMe, }); - if (me.instance_role !== "instance_owner") - throw redirect({ to: "/settings/users" }); + if (!isOperator(me.instance_role)) throw redirect({ to: "/" }); }, - component: StorageSettingsPanel, + component: StorageProfiles, }); +function StorageProfiles() { + const client = useQueryClient(); + const [name, setName] = useState(""); + const [endpoint, setEndpoint] = useState(""); + const [region, setRegion] = useState(""); + const [bucket, setBucket] = useState(""); + const [key, setKey] = useState(""); + const [secret, setSecret] = useState(""); + const { data = [] } = useQuery({ + queryKey: ["storage-profiles"], + queryFn: getStorageProfiles, + }); + const create = useMutation({ + mutationFn: () => + createStorageProfile({ + name, + endpoint, + region: region || null, + bucket, + public_url: null, + access_key_id: key, + secret_access_key: secret, + }), + onSuccess: () => { + setName(""); + setEndpoint(""); + setRegion(""); + setBucket(""); + setKey(""); + setSecret(""); + client.invalidateQueries({ queryKey: ["storage-profiles"] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + const remove = useMutation({ + mutationFn: deleteStorageProfile, + onSuccess: () => + client.invalidateQueries({ queryKey: ["storage-profiles"] }), + onError: (e: Error) => toast.error(e.message), + }); + return ( +
+
+ {data.map((profile) => ( + + +
+
+ {profile.name} + + {profile.kind === "filesystem" + ? "Instance data directory" + : profile.bucket} + +
+ {profile.kind.toUpperCase()} +
+
+ +

+ {profile.endpoint ?? "Built-in provider"} +

+ {!profile.immutable && ( + + )} +
+
+ ))} +
+ + + Add S3-compatible profile + + Save reusable bucket credentials for sites and backups. Credentials + are encrypted and never returned. + + + + + + Short name + setName(e.target.value)} + placeholder="Production media" + /> + + + Endpoint + setEndpoint(e.target.value)} + placeholder="https://..." + /> + +
+ + Region + setRegion(e.target.value)} + /> + + + Bucket + setBucket(e.target.value)} + /> + +
+ + Access key ID + setKey(e.target.value)} + /> + + + + Secret access key + + setSecret(e.target.value)} + /> + + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/deployments.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/deployments.tsx new file mode 100644 index 00000000..d15a55b0 --- /dev/null +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/deployments.tsx @@ -0,0 +1,243 @@ +import { + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { Rocket } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useSiteRole } from "@/components/site-settings/use-site-role"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + createDeployment, + getDeploymentHistory, + getDeployments, + triggerDeployment, +} from "@/lib/api"; + +export const Route = createFileRoute("/_admin/sites/$siteId/deployments")({ + component: DeploymentsPage, +}); + +function DeploymentsPage() { + const { siteId } = Route.useParams(); + const { canManage } = useSiteRole(siteId); + const client = useQueryClient(); + const [label, setLabel] = useState(""); + const [provider, setProvider] = useState("custom"); + const [url, setUrl] = useState(""); + const { data = [] } = useQuery({ + queryKey: ["deployments", siteId], + queryFn: () => getDeployments(siteId), + refetchInterval: 5000, + }); + const histories = useQueries({ + queries: data.map((trigger) => ({ + queryKey: ["deployment-history", siteId, trigger.id], + queryFn: () => getDeploymentHistory(siteId, trigger.id), + refetchInterval: 3000, + })), + }); + const create = useMutation({ + mutationFn: () => + createDeployment(siteId, { + label, + provider, + url, + headers: {}, + enabled: true, + is_primary: data.length === 0, + cooldown_seconds: 60, + daily_quota: 20, + }), + onSuccess: () => { + setLabel(""); + setUrl(""); + client.invalidateQueries({ queryKey: ["deployments", siteId] }); + }, + onError: (error: Error) => toast.error(error.message), + }); + const trigger = useMutation({ + mutationFn: (id: string) => triggerDeployment(siteId, id), + onSuccess: () => { + toast.success("Deployment queued"); + client.invalidateQueries({ queryKey: ["deployment-history", siteId] }); + }, + onError: (error: Error) => toast.error(error.message), + }); + + return ( +
+
+

Deploy

+

+ Publish saved content through configured deployment triggers. +

+
+
+ {data.map((item, index) => { + const latest = histories[index]?.data?.[0]; + return ( + + +
+
+ {item.label} + + {item.provider} ·{" "} + {item.cooldown_seconds === 0 + ? "No cooldown" + : `${item.cooldown_seconds}s cooldown`}{" "} + ·{" "} + {item.daily_quota === 0 + ? "Unlimited" + : `${item.daily_quota}/day`} + +
+
+ {item.is_primary ? Primary : null} + + {item.enabled ? "Ready" : "Disabled"} + +
+
+
+ + {latest ? ( +
+ Latest: + + {latest.status} + + {latest.error_category ? ( + + {latest.error_category.replaceAll("_", " ")} + + ) : null} +
+ ) : null} + +
+
+ ); + })} + {data.length === 0 ? ( + + + No deployment trigger + + An operator must configure a deployment endpoint before editors + can deploy. + + + + ) : null} +
+ {canManage ? ( + + + Add deployment trigger + + Provider URL and headers are encrypted at rest. Defaults: + 60-second cooldown and 20 attempts per rolling day. + + + + + + Label + setLabel(event.target.value)} + placeholder="Production" + /> + + + Provider + + + + Deployment URL + setUrl(event.target.value)} + /> + + + + + + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx index 0ca45444..3bf4f146 100644 --- a/apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx @@ -32,12 +32,23 @@ function SettingsLayout() { + {canManage && ( + } + > + General + + )} } + render={ + + } > - General + MCP ; + const { canManage, role } = useSiteRole(siteId); + return ; } diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/settings/mcp.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/settings/mcp.tsx new file mode 100644 index 00000000..d0552462 --- /dev/null +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/settings/mcp.tsx @@ -0,0 +1,47 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +export const Route = createFileRoute("/_admin/sites/$siteId/settings/mcp")({ + component: McpSettings, +}); +function McpSettings() { + const { siteId } = Route.useParams(); + return ( +
+ + + MCP connection + + Connect an AI client using a personal access token. Your role and + selected scopes limit every operation. + + + +
+

Endpoint

+ {`${window.location.origin}/mcp`} +
+
+

Site ID

+ + {siteId} + +
+

+ Create a PAT with mcp.use plus only needed content/file + scopes. Use this site ID as explicit site context. +

+ +
+
+
+ ); +}