From a7410b90cc61de67e79183cb79b233f75b3c1fde Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:05:48 +0530 Subject: [PATCH 01/16] feat(db): add tokens, deployments, and storage profiles tables New migration creating tables for personal access tokens, deployments (triggers/jobs), and storage profiles across all three backends. --- ...60721000000_tokens_deployments_storage.sql | 14 +++++++ ...60721000000_tokens_deployments_storage.sql | 13 ++++++ ...60721000000_tokens_deployments_storage.sql | 40 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql create mode 100644 apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql create mode 100644 apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql 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 0000000..97c72af --- /dev/null +++ b/apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql @@ -0,0 +1,14 @@ +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); 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 0000000..7e848b7 --- /dev/null +++ b/apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql @@ -0,0 +1,13 @@ +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); 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 0000000..41276ec --- /dev/null +++ b/apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql @@ -0,0 +1,40 @@ +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); From 95f16b4ce7430a406de7c3464ed084492970d6ae Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:06:09 +0530 Subject: [PATCH 02/16] feat(auth): introduce granular token scopes with TokenScope enum Replace simple read/write permission model with fine-grained TokenScope enum supporting site, content, files, schema, webhooks, deployments, and MCP scopes. Add PersonalAccessToken types, encode/decode helpers, and backward-compatible 'read'/'write' aliases. Update authorization model with new Action variants (WebhooksTrigger, Deployments*) and token_hard_denied helper. Extend repository traits with personal token storage interface and implement across all three backends. --- apps/backend/src/models/access_token.rs | 126 +++++++++++++++++- apps/backend/src/models/authorization.rs | 35 ++++- .../src/repository/mysql/access_token.rs | 42 +++++- .../src/repository/postgres/access_token.rs | 42 +++++- .../src/repository/sqlite/access_token.rs | 35 ++++- apps/backend/src/repository/traits.rs | 28 +++- 6 files changed, 281 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/models/access_token.rs b/apps/backend/src/models/access_token.rs index c895937..5eb96c3 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 cca8baf..8e06179 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/repository/mysql/access_token.rs b/apps/backend/src/repository/mysql/access_token.rs index 5df706f..83acb07 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/postgres/access_token.rs b/apps/backend/src/repository/postgres/access_token.rs index 72409a6..2e415f5 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/sqlite/access_token.rs b/apps/backend/src/repository/sqlite/access_token.rs index 5164c0e..deee21c 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/traits.rs b/apps/backend/src/repository/traits.rs index 7d9a667..88b2256 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`]). From b1e2eddeffa326554c2cbfd572b2e0deac0499e3 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:06:22 +0530 Subject: [PATCH 03/16] feat(auth): add personal access token (PAT) authentication and management Introduce PersonalTokenActor with scope-based auth enforcement. Support vcms_pat_ token prefix in API auth middleware. Resolve personal token site context via X-VCMS-Site header. Add PAT CRUD (create/list/revoke) service, handler, and /account/tokens routes. Update GraphQL context to pass requested site and scopes. Provide in-memory PAT repository stubs for testing. --- apps/backend/src/graphql/context.rs | 26 +++-- .../src/handlers/access_token_handler.rs | 81 +++++++++++++- apps/backend/src/middleware/api_auth.rs | 2 +- apps/backend/src/middleware/auth.rs | 95 ++++++++++++++-- apps/backend/src/middleware/site_resolver.rs | 28 ++++- apps/backend/src/router/access_tokens.rs | 7 ++ apps/backend/src/services/access_token.rs | 101 ++++++++++++++++-- apps/backend/src/test_helpers.rs | 25 +++++ 8 files changed, 334 insertions(+), 31 deletions(-) diff --git a/apps/backend/src/graphql/context.rs b/apps/backend/src/graphql/context.rs index 87e9907..0acde96 100644 --- a/apps/backend/src/graphql/context.rs +++ b/apps/backend/src/graphql/context.rs @@ -1,5 +1,5 @@ use crate::middleware::auth::{Actor, verify_access_token}; -use crate::models::access_token::AccessTokenPermission; +use crate::models::access_token::{TokenScope, TokenScopes}; use crate::repository::Repository; use crate::services::Services; @@ -8,7 +8,7 @@ pub struct GqlContext { pub services: Services, pub actor: Option, pub site_id: Option, - pub permission: Option, + pub permission: Option, } impl GqlContext { @@ -16,6 +16,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 +25,15 @@ 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 { + site_id = requested_site.map(str::to_owned); + permission = Some(k.scopes.clone()); } actor = Some(auth_actor); } @@ -51,8 +55,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 +69,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 07f4da5..4878195 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/middleware/api_auth.rs b/apps/backend/src/middleware/api_auth.rs index f457a08..86183de 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 d9e11cb..1674205 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 a7c9e85..923f1ee 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/router/access_tokens.rs b/apps/backend/src/router/access_tokens.rs index 568f065..0b1a766 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/services/access_token.rs b/apps/backend/src/services/access_token.rs index c521dad..e3879c8 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/test_helpers.rs b/apps/backend/src/test_helpers.rs index 63adb06..0309b3f 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)] From 26cf1a27a93b795a60927ebb4e13c218475fe677 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:06:31 +0530 Subject: [PATCH 04/16] feat: register new modules and wire services Register deployment and storage_profile modules in lib.rs and services. Add deployment and storage profile routes in router. Wire storage profile registration and deployment reconcilation at server startup. Add storage_profile_id to CLI backup create options. --- apps/backend/src/lib.rs | 4 ++++ apps/backend/src/main.rs | 1 + apps/backend/src/router/mod.rs | 3 +++ apps/backend/src/server.rs | 8 ++++++++ apps/backend/src/services/mod.rs | 16 +++++++++++++--- 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/lib.rs b/apps/backend/src/lib.rs index a64b08c..90489b7 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 66662c1..d1b7c42 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/router/mod.rs b/apps/backend/src/router/mod.rs index dc9a759..8f35ec5 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 dc7ba13..c36df06 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/mod.rs b/apps/backend/src/services/mod.rs index 50e79fb..76bdb85 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, From 65fbf3bd0a37d59ba75e358cd32afbd5b819d7d5 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:06:44 +0530 Subject: [PATCH 05/16] feat(storage): add storage profile management Introduce StorageProfile model, service (CRUD + probe), and HTTP handler. Add storage_profile_id optional column to Site model and include it in all site queries across backends. Assign storage profile on site creation. Expose profile ID in site listing responses. Wire storage profile CRUD and probe routes under /instance/storage-profiles. Update file storage resolution to COALESCE storage profile over legacy storage_provider. --- apps/backend/src/handlers/site_handler.rs | 10 +- .../src/handlers/storage_profile_handler.rs | 108 +++++++ apps/backend/src/models/site.rs | 9 + apps/backend/src/models/storage_profile.rs | 45 +++ apps/backend/src/repository/mysql/file.rs | 9 +- apps/backend/src/repository/mysql/site.rs | 6 +- apps/backend/src/repository/postgres/file.rs | 9 +- apps/backend/src/repository/postgres/site.rs | 6 +- apps/backend/src/repository/sqlite/file.rs | 9 +- apps/backend/src/repository/sqlite/site.rs | 6 +- apps/backend/src/router/instance.rs | 13 + apps/backend/src/services/site.rs | 7 +- apps/backend/src/services/storage_profile.rs | 300 ++++++++++++++++++ 13 files changed, 512 insertions(+), 25 deletions(-) create mode 100644 apps/backend/src/handlers/storage_profile_handler.rs create mode 100644 apps/backend/src/models/storage_profile.rs create mode 100644 apps/backend/src/services/storage_profile.rs diff --git a/apps/backend/src/handlers/site_handler.rs b/apps/backend/src/handlers/site_handler.rs index c150048..6e7bc6d 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 0000000..fbd9623 --- /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/models/site.rs b/apps/backend/src/models/site.rs index d12defd..df0490f 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 0000000..4bb726d --- /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/file.rs b/apps/backend/src/repository/mysql/file.rs index 47caa3f..c5879c0 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 61b8bff..6b40e46 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/file.rs b/apps/backend/src/repository/postgres/file.rs index ecef75f..e9fce24 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 23c9bf9..723ac1b 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/file.rs b/apps/backend/src/repository/sqlite/file.rs index 542974e..9eae979 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 cfd22b4..0287471 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/router/instance.rs b/apps/backend/src/router/instance.rs index f1171b2..556489a 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/services/site.rs b/apps/backend/src/services/site.rs index 4bc6bb4..ae6bf7a 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 0000000..269e8f9 --- /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))) + } +} From dd5b1f38f34995fc85d489ba8a2727626739360b Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:06:56 +0530 Subject: [PATCH 06/16] feat(backup): support storage profile destination for backups Add DestinationSpec::Profile variant for routing backups through configured storage profiles. Include storage_profile_id in backup and schedule creation payloads. Persist profile ID in backup metadata tables. --- apps/backend/src/handlers/backup_handler.rs | 6 +++++ apps/backend/src/services/backup/meta.rs | 26 ++++++++++++++----- apps/backend/src/services/backup/mod.rs | 21 ++++++++++++++- apps/backend/src/services/backup/scheduler.rs | 1 + 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/handlers/backup_handler.rs b/apps/backend/src/handlers/backup_handler.rs index 61c7de1..74d3335 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/services/backup/meta.rs b/apps/backend/src/services/backup/meta.rs index 4ebb5f1..0a13614 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 84f07e1..3907129 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 96bdfed..fb3018f 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"); From 9098456d55a0ade5f272dc090263c66ae869182d Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:07:09 +0530 Subject: [PATCH 07/16] feat(deploy): add deployment triggers and job management Add DeploymentTrigger and DeploymentJob models, service with reconcile_interrupted for crash recovery, HTTP handler, and routes under /sites/{site_id}/deployments. Extend WebhookService with protect/reveal_deployment_config helpers for encrypting deployment URL and headers. --- .../src/handlers/deployment_handler.rs | 111 ++++++ apps/backend/src/models/deployment.rs | 60 +++ apps/backend/src/router/deployments.rs | 18 + apps/backend/src/services/deployment.rs | 342 ++++++++++++++++++ apps/backend/src/services/webhook.rs | 26 ++ 5 files changed, 557 insertions(+) create mode 100644 apps/backend/src/handlers/deployment_handler.rs create mode 100644 apps/backend/src/models/deployment.rs create mode 100644 apps/backend/src/router/deployments.rs create mode 100644 apps/backend/src/services/deployment.rs diff --git a/apps/backend/src/handlers/deployment_handler.rs b/apps/backend/src/handlers/deployment_handler.rs new file mode 100644 index 0000000..0fa5a9b --- /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/models/deployment.rs b/apps/backend/src/models/deployment.rs new file mode 100644 index 0000000..581d3e8 --- /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/router/deployments.rs b/apps/backend/src/router/deployments.rs new file mode 100644 index 0000000..6cf27c4 --- /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/services/deployment.rs b/apps/backend/src/services/deployment.rs new file mode 100644 index 0000000..21ecd7f --- /dev/null +++ b/apps/backend/src/services/deployment.rs @@ -0,0 +1,342 @@ +use std::{sync::Arc, time::Instant}; + +use crate::{ + database::pool::DbPool, + models::deployment::{CreateDeploymentTrigger, DeploymentJob, DeploymentTrigger}, + services::webhook::WebhookService, +}; +use uuid::Uuid; + +#[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(|e| e.to_string()), + 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(|e| e.to_string()), + 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(|e| e.to_string()), + }?; + 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)) => { + let mut request = reqwest::Client::new().post(url); + for (k, v) in headers { + request = request.header(k, v) + } + request.send().await.map_err(|e| e.to_string()).map(|r| { + ( + r.status().as_u16() as i32, + r.headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()), + ) + }) + } + 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/webhook.rs b/apps/backend/src/services/webhook.rs index 48aa5a2..bdc7c25 100644 --- a/apps/backend/src/services/webhook.rs +++ b/apps/backend/src/services/webhook.rs @@ -101,6 +101,32 @@ 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)) + } + fn allow_private_targets(&self) -> bool { self.settings .as_ref() From 0582072c549bb1980580bd1e5535962b59c4d7dd Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:07:21 +0530 Subject: [PATCH 08/16] feat(mcp): add list_sites tool and enforce required site_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add list_sites tool returning sites accessible to the authenticated user. Enforce that all site-scoped MCP tools require an explicit site_id parameter (except list_sites). Clean up whitespace (CRLF→LF) and update test assertions to match. --- apps/backend/src/mcp/auth.rs | 12 +- apps/backend/src/mcp/resources/site_schema.rs | 79 +- apps/backend/src/mcp/server.rs | 1310 +++++++++-------- apps/backend/src/mcp/tools/collection.rs | 27 +- apps/backend/src/mcp/tools/entry.rs | 34 +- apps/backend/src/mcp/tools/file.rs | 22 +- apps/backend/src/mcp/tools/singleton.rs | 21 +- apps/backend/src/mcp/tools/site.rs | 18 +- apps/backend/src/mcp/tools/webhook.rs | 33 +- 9 files changed, 782 insertions(+), 774 deletions(-) diff --git a/apps/backend/src/mcp/auth.rs b/apps/backend/src/mcp/auth.rs index fd46455..9755595 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 893a396..a2868a5 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 23264ea..12d54c6 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 97c8119..c126e12 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 8f045ce..f290a6d 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 8019c32..0773a34 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 5baa110..390b765 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 2866f71..89e114a 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 d3143ac..a98dcf8 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 From 30b7d21738e4fbd1c2f51a79d5ab77834386265b Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:07:28 +0530 Subject: [PATCH 09/16] feat(dashboard): update API client with new endpoints and types Add API functions and TypeScript types for scoped site tokens, personal access tokens (PAT), storage profiles (CRUD + probe), deployments (triggers, jobs, history), and storage_profile_id on backups and sites. --- apps/dashboard/src/lib/api.ts | 147 ++++++++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 363c3a2..a942d3f 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -196,6 +196,7 @@ export interface Site { id: string; name: string; storage_provider: string; + storage_profile_id: string | null; created_by: string; created_at: string; updated_at: string; @@ -366,7 +367,7 @@ interface AccessToken { site_id: string; name: string; token_prefix: string; - permission: "read" | "write"; + permission: string; last_used_at: string | null; created_at: string; expires_at: string | null; @@ -378,17 +379,26 @@ interface AccessTokenResponse { name: string; token: string; token_prefix: string; - permission: "read" | "write"; + scopes: string[]; created_at: string; } function mapAccessToken(token: AccessToken): ApiKey { + const scopes = (() => { + try { + return JSON.parse(token.permission) as string[]; + } catch { + return [token.permission]; + } + })(); return { id: token.id, site_id: token.site_id, name: token.name, key_prefix: token.token_prefix, - permissions: token.permission, + permissions: scopes.some((scope) => scope.endsWith(".write")) + ? "write" + : "read", last_used_at: token.last_used_at, created_at: token.created_at, expires_at: token.expires_at, @@ -402,7 +412,7 @@ function mapCreatedAccessToken(token: AccessTokenResponse): ApiKeyResponse { name: token.name, key: token.token, key_prefix: token.token_prefix, - permissions: token.permission, + permissions: token.scopes.join(", "), created_at: token.created_at, }; } @@ -672,6 +682,7 @@ export interface BackupInfo { created_by: string | null; completed_at: string | null; created_at: string; + storage_profile_id: string | null; } export interface BackupSchedule { @@ -686,11 +697,13 @@ export interface BackupSchedule { last_run_at: string | null; next_run_at: string | null; created_at: string; + storage_profile_id: string | null; } export interface CreateBackupInput { include_files?: boolean; encrypt?: boolean; + storage_profile_id?: string; } export interface ScheduleInput { @@ -699,6 +712,7 @@ export interface ScheduleInput { include_files: boolean; encrypt: boolean; enabled: boolean; + storage_profile_id?: string; } export interface RestoreInput { @@ -898,6 +912,7 @@ export async function getSites() { export async function createSite(data: { name: string; storage_provider?: string; + storage_profile_id?: string; }) { return api("/sites", { method: "POST", @@ -963,13 +978,135 @@ export async function createApiKey( name: string, permissions?: string, ) { + const scopes = + permissions === "write" + ? [ + "site.read", + "site.settings.read", + "site.settings.write", + "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", + ] + : permissions === "read" + ? ["site.read", "content.read", "files.read", "schema.read"] + : (permissions + ?.split(",") + .map((value) => value.trim()) + .filter(Boolean) ?? ["site.read", "content.read"]); const token = await api(`/sites/${siteId}/tokens`, { method: "POST", - body: JSON.stringify({ name, permission: permissions ?? "read" }), + body: JSON.stringify({ name, scopes }), }); return mapCreatedAccessToken(token); } +export interface PersonalToken { + id: string; + name: string; + token_prefix: string; + scopes: string[]; + last_used_at: string | null; + created_at: string; + expires_at: string | null; + revoked_at: string | null; +} +export interface CreatedPersonalToken extends PersonalToken { + token: string; +} +export const getPersonalTokens = () => api("/account/tokens"); +export const createPersonalToken = (value: { + name: string; + scopes: string[]; + expires_at: string | null; +}) => + api<{ token_info: PersonalToken; token: string }>("/account/tokens", { + method: "POST", + body: JSON.stringify(value), + }); +export const revokePersonalToken = (id: string) => + api(`/account/tokens/${id}`, { method: "DELETE" }); + +export interface DeploymentTrigger { + id: string; + site_id: string; + label: string; + provider: string; + enabled: boolean; + is_primary: boolean; + cooldown_seconds: number; + daily_quota: number; + created_at: string; + updated_at: string; +} +export interface DeploymentJob { + id: string; + status: string; + status_code: number | null; + error_category: string | null; + retry_after_seconds: number | null; + created_at: string; + finished_at: string | null; +} +export const getDeployments = (siteId: string) => + api(`/sites/${siteId}/deployments`); +export const createDeployment = (siteId: string, value: unknown) => + api(`/sites/${siteId}/deployments`, { + method: "POST", + body: JSON.stringify(value), + }); +export const updateDeployment = (siteId: string, id: string, value: unknown) => + api(`/sites/${siteId}/deployments/${id}`, { + method: "PUT", + body: JSON.stringify(value), + }); +export const triggerDeployment = (siteId: string, id: string) => + api(`/sites/${siteId}/deployments/${id}/trigger`, { + method: "POST", + }); +export const getDeploymentHistory = (siteId: string, id: string) => + api(`/sites/${siteId}/deployments/${id}/history`); + +export interface StorageProfile { + id: string; + name: string; + kind: "filesystem" | "s3"; + endpoint: string | null; + region: string | null; + bucket: string | null; + public_url: string | null; + enabled: boolean; + immutable: boolean; +} +export const getStorageProfiles = () => + api("/instance/storage-profiles"); +export const createStorageProfile = (value: unknown) => + api("/instance/storage-profiles", { + method: "POST", + body: JSON.stringify(value), + }); +export const updateStorageProfile = (id: string, value: unknown) => + api(`/instance/storage-profiles/${id}`, { + method: "PUT", + body: JSON.stringify(value), + }); +export const probeStorageProfile = (id: string) => + api<{ ok: boolean }>(`/instance/storage-profiles/${id}/probe`, { + method: "POST", + }); +export const deleteStorageProfile = (id: string) => + api(`/instance/storage-profiles/${id}`, { method: "DELETE" }); + export async function deleteApiKey(siteId: string, keyId: string) { return api(`/sites/${siteId}/tokens/${keyId}`, { method: "DELETE", From ca2b3a3d4d5b4b4289f1a417d425fb67a0fb4aed Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:07:39 +0530 Subject: [PATCH 10/16] feat(dashboard): add personal access tokens UI Add PersonalTokensCard component for creating, listing, and revoking personal access tokens. Integrate into the account page. --- .../account/personal-tokens-card.tsx | 204 ++++++++++++++++++ .../src/routes/_admin/_shell/account.tsx | 3 + 2 files changed, 207 insertions(+) create mode 100644 apps/dashboard/src/components/account/personal-tokens-card.tsx 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 0000000..4edaa84 --- /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/routes/_admin/_shell/account.tsx b/apps/dashboard/src/routes/_admin/_shell/account.tsx index 4d502c6..241ac2b 100644 --- a/apps/dashboard/src/routes/_admin/_shell/account.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/account.tsx @@ -3,6 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { KeyRound, LogOut } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { PersonalTokensCard } from "@/components/account/personal-tokens-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -153,6 +154,8 @@ function AccountPage() { + + Change password From f23f7b629c06e10fadb77342995b210f674fcbcf Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:07:54 +0530 Subject: [PATCH 11/16] feat(dashboard): update storage settings and site creation with storage profiles Rebuild storage settings page with storage profile CRUD (create, list, probe, delete). Add storage profile picker to the create-site dialog. Require operator role instead of instance_owner only. --- .../src/routes/_admin/_shell/index.tsx | 52 +++-- .../routes/_admin/_shell/settings/storage.tsx | 191 +++++++++++++++++- 2 files changed, 215 insertions(+), 28 deletions(-) diff --git a/apps/dashboard/src/routes/_admin/_shell/index.tsx b/apps/dashboard/src/routes/_admin/_shell/index.tsx index e2506d4..7f54e60 100644 --- a/apps/dashboard/src/routes/_admin/_shell/index.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/index.tsx @@ -45,6 +45,7 @@ import { useAuth } from "@/contexts/auth-context"; import { createSite, getSites, + getStorageProfiles, isOperator, type SiteWithRole, siteRoleLabel, @@ -116,6 +117,11 @@ function CreateSiteDialog({ }) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { data: storageProfiles = [] } = useQuery({ + queryKey: ["storage-profiles"], + queryFn: getStorageProfiles, + enabled: open, + }); const createMutation = useMutation({ mutationFn: ({ @@ -127,7 +133,10 @@ function CreateSiteDialog({ }) => createSite({ name, - storage_provider: storageProvider, + storage_provider: + storageProfiles.find((profile) => profile.id === storageProvider) + ?.kind ?? "filesystem", + storage_profile_id: storageProvider, }), onSuccess: (site) => { queryClient.invalidateQueries({ queryKey: ["sites"] }); @@ -145,7 +154,7 @@ function CreateSiteDialog({ const form = useForm({ defaultValues: { name: "", - storageProvider: "filesystem", + storageProvider: "local-filesystem", }, validators: { onSubmit: createSiteSchema, @@ -220,12 +229,16 @@ function CreateSiteDialog({ !field.state.meta.isValid } > - {field.state.value === "filesystem" ? ( + {storageProfiles.find( + (profile) => profile.id === field.state.value, + )?.kind === "filesystem" ? (
Filesystem
- ) : field.state.value === "s3" ? ( + ) : storageProfiles.find( + (profile) => profile.id === field.state.value, + )?.kind === "s3" ? (
S3 / Cloud Storage @@ -235,27 +248,22 @@ function CreateSiteDialog({ )} - -
- - Filesystem - - (default) - -
-
- -
- - S3 / Cloud Storage -
-
+ {storageProfiles.map((profile) => ( + +
+ {profile.kind === "filesystem" ? ( + + ) : ( + + )} + {profile.name} +
+
+ ))}

- {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/storage.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx index d7a80de..ac3112a 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx @@ -1,15 +1,194 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; -import { StorageSettingsPanel } from "@/components/instance/settings-forms"; -import { getMe } from "@/lib/api"; - +import { Database, 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 { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + createStorageProfile, + deleteStorageProfile, + getMe, + getStorageProfiles, + isOperator, +} from "@/lib/api"; export const Route = createFileRoute("/_admin/_shell/settings/storage")({ beforeLoad: async ({ context }) => { 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)} + /> + + +
+
+
+
+ ); +} From d6d50e160968355c3cbeb87fc15fdc76048d26ce Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:08:14 +0530 Subject: [PATCH 12/16] feat(auth): wire X-VCMS-Site through GraphQL and add scope checks to authorization service Pass X-VCMS-Site header from GraphQL router to GqlContext for personal token site resolution. Update AuthorizationService to use scope_for_action and PersonalTokenActor for scope-based enforcement. --- apps/backend/src/router/graphql.rs | 10 +++++++++- apps/backend/src/services/authorization.rs | 20 ++++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/router/graphql.rs b/apps/backend/src/router/graphql.rs index 4514895..c6a873f 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/services/authorization.rs b/apps/backend/src/services/authorization.rs index a7a9f91..cfbce1c 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")); From 434500c2ead7c056efe85714facc2773ec257f0a Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:08:34 +0530 Subject: [PATCH 13/16] feat(dashboard): add deployments page, MCP settings, storage profile backup selector, and update routing Add deployments management page under site settings. Add MCP configuration page. Add storage profile selector to backup creation form. Update sidebar with new navigation items. Regenerate route tree. Pass role prop to GeneralSection. Update site settings layout and routing. --- .../components/backups/backups-section.tsx | 64 ++++- .../src/components/sidebar/app-sidebar.tsx | 33 ++- .../site-settings/general-section.tsx | 28 ++ apps/dashboard/src/routeTree.gen.ts | 44 ++++ .../src/routes/_admin/_shell/settings.tsx | 2 +- .../_admin/sites.$siteId/deployments.tsx | 243 ++++++++++++++++++ .../routes/_admin/sites.$siteId/settings.tsx | 17 +- .../_admin/sites.$siteId/settings/index.tsx | 4 +- .../_admin/sites.$siteId/settings/mcp.tsx | 47 ++++ 9 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 apps/dashboard/src/routes/_admin/sites.$siteId/deployments.tsx create mode 100644 apps/dashboard/src/routes/_admin/sites.$siteId/settings/mcp.tsx diff --git a/apps/dashboard/src/components/backups/backups-section.tsx b/apps/dashboard/src/components/backups/backups-section.tsx index 3452e4c..4ef5153 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 + +
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 0ca4544..3bf4f14 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 0000000..d055246 --- /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. +

+ +
+
+
+ ); +} From 12889648e597d6c0dcf7537f0661b0fb7089bb39 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:53:59 +0530 Subject: [PATCH 14/16] fix(db): add unique partial index to prevent concurrent active deployment jobs Add partial unique index on deployment_jobs to enforce at most one queued or running job per trigger. MySQL uses a generated column approach, Postgres and SQLite use filtered unique indexes. --- .../mysql/20260721000000_tokens_deployments_storage.sql | 2 ++ .../postgres/20260721000000_tokens_deployments_storage.sql | 1 + .../sqlite/20260721000000_tokens_deployments_storage.sql | 2 ++ 3 files changed, 5 insertions(+) diff --git a/apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql b/apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql index 97c72af..003fe27 100644 --- a/apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql +++ b/apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql @@ -12,3 +12,5 @@ ALTER TABLE backup_schedules ADD CONSTRAINT fk_backup_schedules_storage_profile 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 index 7e848b7..aa6e071 100644 --- a/apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql +++ b/apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql @@ -11,3 +11,4 @@ CREATE TABLE IF NOT EXISTS deployment_triggers (id TEXT PRIMARY KEY,site_id TEXT 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 index 41276ec..abd818a 100644 --- a/apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql +++ b/apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql @@ -38,3 +38,5 @@ CREATE TABLE IF NOT EXISTS deployment_jobs ( 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'); From c46d4a69df02fa5f60bfaf882d10a220d7e5507d Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:54:08 +0530 Subject: [PATCH 15/16] feat(webhook): extract build_protected_client and integrate into deployment service Extract build_protected_client from deliver_to_url as a reusable method for deployments. Refactor deployment execute_job to use it. Add map_job_insert_error returning 'deployment_in_progress' on unique violation and a unit test for active job uniqueness. --- apps/backend/src/services/deployment.rs | 90 ++++++++++++++++++++----- apps/backend/src/services/webhook.rs | 32 ++++----- 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/apps/backend/src/services/deployment.rs b/apps/backend/src/services/deployment.rs index 21ecd7f..420aa9b 100644 --- a/apps/backend/src/services/deployment.rs +++ b/apps/backend/src/services/deployment.rs @@ -7,6 +7,58 @@ use crate::{ }; 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, @@ -230,7 +282,7 @@ impl DeploymentService { .execute(p) .await .map(|_| ()) - .map_err(|e| e.to_string()), + .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)", ) @@ -241,7 +293,7 @@ impl DeploymentService { .execute(p) .await .map(|_| ()) - .map_err(|e| e.to_string()), + .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',?)", ) @@ -252,7 +304,7 @@ impl DeploymentService { .execute(p) .await .map(|_| ()) - .map_err(|e| e.to_string()), + .map_err(map_job_insert_error), }?; self.history(trigger_id) .await? @@ -269,21 +321,25 @@ impl DeploymentService { .map_err(|e| e.to_string()) }); let result = match result { - Ok((url, headers)) => { - let mut request = reqwest::Client::new().post(url); - for (k, v) in headers { - request = request.header(k, v) + 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()), + ) + }) } - request.send().await.map_err(|e| e.to_string()).map(|r| { - ( - r.status().as_u16() as i32, - r.headers() - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()), - ) - }) - } + Err(error) => Err(error.to_string()), + }, Err(e) => Err(e), }; let (status, code, category, retry) = match result { diff --git a/apps/backend/src/services/webhook.rs b/apps/backend/src/services/webhook.rs index bdc7c25..8653a6f 100644 --- a/apps/backend/src/services/webhook.rs +++ b/apps/backend/src/services/webhook.rs @@ -127,6 +127,19 @@ impl WebhookService { 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() @@ -320,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!( From f2c3f284a9747b213292ef5c8ff897c31e072a80 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:54:16 +0530 Subject: [PATCH 16/16] fix(auth): verify site access before resolving site for personal tokens in GraphQL When a personal token supplies X-VCMS-Site, verify the user has SiteRead access before trusting the header, rather than blindly accepting the requested site. --- apps/backend/src/graphql/context.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/graphql/context.rs b/apps/backend/src/graphql/context.rs index 0acde96..02e9e79 100644 --- a/apps/backend/src/graphql/context.rs +++ b/apps/backend/src/graphql/context.rs @@ -1,7 +1,9 @@ use crate::middleware::auth::{Actor, verify_access_token}; 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, @@ -32,8 +34,15 @@ impl GqlContext { site_id = Some(k.site_id.clone()); permission = Some(k.scopes.clone()); } else if let Actor::PersonalToken(k) = &auth_actor { - site_id = requested_site.map(str::to_owned); 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); }