Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a7410b9
feat(db): add tokens, deployments, and storage profiles tables
krishna-santosh Jul 21, 2026
95f16b4
feat(auth): introduce granular token scopes with TokenScope enum
krishna-santosh Jul 21, 2026
b1e2edd
feat(auth): add personal access token (PAT) authentication and manage…
krishna-santosh Jul 21, 2026
26cf1a2
feat: register new modules and wire services
krishna-santosh Jul 21, 2026
65fbf3b
feat(storage): add storage profile management
krishna-santosh Jul 21, 2026
dd5b1f3
feat(backup): support storage profile destination for backups
krishna-santosh Jul 21, 2026
9098456
feat(deploy): add deployment triggers and job management
krishna-santosh Jul 21, 2026
0582072
feat(mcp): add list_sites tool and enforce required site_id
krishna-santosh Jul 21, 2026
30b7d21
feat(dashboard): update API client with new endpoints and types
krishna-santosh Jul 21, 2026
ca2b3a3
feat(dashboard): add personal access tokens UI
krishna-santosh Jul 21, 2026
f23f7b6
feat(dashboard): update storage settings and site creation with stora…
krishna-santosh Jul 21, 2026
d6d50e1
feat(auth): wire X-VCMS-Site through GraphQL and add scope checks to …
krishna-santosh Jul 21, 2026
434500c
feat(dashboard): add deployments page, MCP settings, storage profile …
krishna-santosh Jul 21, 2026
1288964
fix(db): add unique partial index to prevent concurrent active deploy…
krishna-santosh Jul 22, 2026
c46d4a6
feat(webhook): extract build_protected_client and integrate into depl…
krishna-santosh Jul 22, 2026
f2c3f28
fix(auth): verify site access before resolving site for personal toke…
krishna-santosh Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS personal_access_tokens (id VARCHAR(36) PRIMARY KEY,user_id VARCHAR(36) NOT NULL,name VARCHAR(255) NOT NULL,token_hash TEXT NOT NULL,token_hmac VARCHAR(128) NOT NULL,token_prefix VARCHAR(32) NOT NULL,scopes_json TEXT NOT NULL,created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,last_used_at TIMESTAMP NULL,expires_at TIMESTAMP NULL,revoked_at TIMESTAMP NULL,FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE);
CREATE INDEX idx_pat_prefix ON personal_access_tokens(token_prefix);
ALTER TABLE access_tokens ADD COLUMN scopes_json TEXT NOT NULL DEFAULT ('[]');
CREATE TABLE IF NOT EXISTS storage_profiles (id VARCHAR(36) PRIMARY KEY,name VARCHAR(255) NOT NULL UNIQUE,kind VARCHAR(20) NOT NULL,endpoint TEXT,region VARCHAR(255),bucket VARCHAR(255),public_url TEXT,credentials_encrypted TEXT,enabled BOOLEAN NOT NULL DEFAULT TRUE,immutable BOOLEAN NOT NULL DEFAULT FALSE,created_by VARCHAR(36),created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY(created_by) REFERENCES users(id));
INSERT IGNORE INTO storage_profiles(id,name,kind,enabled,immutable) VALUES ('local-filesystem','Local Filesystem','filesystem',TRUE,TRUE);
ALTER TABLE sites ADD COLUMN storage_profile_id VARCHAR(36) NULL;
UPDATE sites SET storage_profile_id='local-filesystem' WHERE storage_profile_id IS NULL;
ALTER TABLE backups ADD COLUMN storage_profile_id VARCHAR(36) NULL;
ALTER TABLE backup_schedules ADD COLUMN storage_profile_id VARCHAR(36) NULL;
ALTER TABLE backups ADD CONSTRAINT fk_backups_storage_profile FOREIGN KEY (storage_profile_id) REFERENCES storage_profiles(id);
ALTER TABLE backup_schedules ADD CONSTRAINT fk_backup_schedules_storage_profile FOREIGN KEY (storage_profile_id) REFERENCES storage_profiles(id);
CREATE TABLE IF NOT EXISTS deployment_triggers (id VARCHAR(36) PRIMARY KEY,site_id VARCHAR(36) NOT NULL,label VARCHAR(255) NOT NULL,provider VARCHAR(32) NOT NULL,url_encrypted TEXT NOT NULL,headers_encrypted TEXT NOT NULL,enabled BOOLEAN NOT NULL DEFAULT TRUE,is_primary BOOLEAN NOT NULL DEFAULT FALSE,cooldown_seconds BIGINT NOT NULL DEFAULT 60,daily_quota BIGINT NOT NULL DEFAULT 20,created_by VARCHAR(36),created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE,FOREIGN KEY(created_by) REFERENCES users(id));
CREATE TABLE IF NOT EXISTS deployment_jobs (id VARCHAR(36) PRIMARY KEY,trigger_id VARCHAR(36) NOT NULL,site_id VARCHAR(36) NOT NULL,status VARCHAR(32) NOT NULL,status_code INTEGER,error_category VARCHAR(32),response_body TEXT,retry_after_seconds BIGINT,duration_ms BIGINT,triggered_by VARCHAR(36),created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,started_at TIMESTAMP NULL,finished_at TIMESTAMP NULL,FOREIGN KEY(trigger_id) REFERENCES deployment_triggers(id) ON DELETE CASCADE,FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE);
CREATE INDEX idx_deployment_jobs_trigger ON deployment_jobs(trigger_id,created_at);
ALTER TABLE deployment_jobs ADD COLUMN active_trigger_id VARCHAR(36) GENERATED ALWAYS AS (CASE WHEN status IN ('queued','running') THEN trigger_id ELSE NULL END) STORED;
CREATE UNIQUE INDEX idx_deployment_jobs_active_trigger ON deployment_jobs(active_trigger_id);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS personal_access_tokens (id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,name TEXT NOT NULL,token_hash TEXT NOT NULL,token_hmac TEXT NOT NULL,token_prefix TEXT NOT NULL,scopes_json TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),last_used_at TIMESTAMPTZ,expires_at TIMESTAMPTZ,revoked_at TIMESTAMPTZ);
CREATE INDEX IF NOT EXISTS idx_pat_prefix ON personal_access_tokens(token_prefix);
ALTER TABLE access_tokens ADD COLUMN IF NOT EXISTS scopes_json TEXT NOT NULL DEFAULT '[]';
CREATE TABLE IF NOT EXISTS storage_profiles (id TEXT PRIMARY KEY,name TEXT NOT NULL UNIQUE,kind TEXT NOT NULL CHECK(kind IN ('filesystem','s3')),endpoint TEXT,region TEXT,bucket TEXT,public_url TEXT,credentials_encrypted TEXT,enabled BOOLEAN NOT NULL DEFAULT TRUE,immutable BOOLEAN NOT NULL DEFAULT FALSE,created_by TEXT REFERENCES users(id),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
INSERT INTO storage_profiles(id,name,kind,enabled,immutable) VALUES ('local-filesystem','Local Filesystem','filesystem',TRUE,TRUE) ON CONFLICT DO NOTHING;
ALTER TABLE sites ADD COLUMN IF NOT EXISTS storage_profile_id TEXT REFERENCES storage_profiles(id);
UPDATE sites SET storage_profile_id='local-filesystem' WHERE storage_profile_id IS NULL;
ALTER TABLE backups ADD COLUMN IF NOT EXISTS storage_profile_id TEXT REFERENCES storage_profiles(id);
ALTER TABLE backup_schedules ADD COLUMN IF NOT EXISTS storage_profile_id TEXT REFERENCES storage_profiles(id);
CREATE TABLE IF NOT EXISTS deployment_triggers (id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,label TEXT NOT NULL,provider TEXT NOT NULL,url_encrypted TEXT NOT NULL,headers_encrypted TEXT NOT NULL,enabled BOOLEAN NOT NULL DEFAULT TRUE,is_primary BOOLEAN NOT NULL DEFAULT FALSE,cooldown_seconds BIGINT NOT NULL DEFAULT 60,daily_quota BIGINT NOT NULL DEFAULT 20,created_by TEXT REFERENCES users(id),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_primary ON deployment_triggers(site_id) WHERE is_primary=TRUE;
CREATE TABLE IF NOT EXISTS deployment_jobs (id TEXT PRIMARY KEY,trigger_id TEXT NOT NULL REFERENCES deployment_triggers(id) ON DELETE CASCADE,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,status TEXT NOT NULL,status_code INTEGER,error_category TEXT,response_body TEXT,retry_after_seconds BIGINT,duration_ms BIGINT,triggered_by TEXT,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),started_at TIMESTAMPTZ,finished_at TIMESTAMPTZ);
CREATE INDEX IF NOT EXISTS idx_deployment_jobs_trigger ON deployment_jobs(trigger_id,created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_jobs_active_trigger ON deployment_jobs(trigger_id) WHERE status IN ('queued','running');
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
CREATE TABLE IF NOT EXISTS personal_access_tokens (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, token_hash TEXT NOT NULL, token_hmac TEXT NOT NULL,
token_prefix TEXT NOT NULL, scopes_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used_at TEXT,
expires_at TEXT, revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_pat_prefix ON personal_access_tokens(token_prefix);
ALTER TABLE access_tokens ADD COLUMN scopes_json TEXT NOT NULL DEFAULT '[]';

CREATE TABLE IF NOT EXISTS storage_profiles (
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, kind TEXT NOT NULL CHECK(kind IN ('filesystem','s3')),
endpoint TEXT, region TEXT, bucket TEXT, public_url TEXT, credentials_encrypted TEXT,
enabled INTEGER NOT NULL DEFAULT 1, immutable INTEGER NOT NULL DEFAULT 0,
created_by TEXT REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO storage_profiles(id,name,kind,enabled,immutable) VALUES ('local-filesystem','Local Filesystem','filesystem',1,1);
ALTER TABLE sites ADD COLUMN storage_profile_id TEXT REFERENCES storage_profiles(id);
UPDATE sites SET storage_profile_id = 'local-filesystem' WHERE storage_profile_id IS NULL;
ALTER TABLE backups ADD COLUMN storage_profile_id TEXT REFERENCES storage_profiles(id);
ALTER TABLE backup_schedules ADD COLUMN storage_profile_id TEXT REFERENCES storage_profiles(id);

CREATE TABLE IF NOT EXISTS deployment_triggers (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
label TEXT NOT NULL, provider TEXT NOT NULL, url_encrypted TEXT NOT NULL, headers_encrypted TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1, is_primary INTEGER NOT NULL DEFAULT 0,
cooldown_seconds INTEGER NOT NULL DEFAULT 60, daily_quota INTEGER NOT NULL DEFAULT 20,
created_by TEXT REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_primary ON deployment_triggers(site_id) WHERE is_primary = 1;
CREATE TABLE IF NOT EXISTS deployment_jobs (
id TEXT PRIMARY KEY, trigger_id TEXT NOT NULL REFERENCES deployment_triggers(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, status TEXT NOT NULL,
status_code INTEGER, error_category TEXT, response_body TEXT, retry_after_seconds INTEGER,
duration_ms INTEGER, triggered_by TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT, finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_deployment_jobs_trigger ON deployment_jobs(trigger_id, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_jobs_active_trigger
ON deployment_jobs(trigger_id) WHERE status IN ('queued', 'running');
35 changes: 25 additions & 10 deletions apps/backend/src/graphql/context.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
use crate::middleware::auth::{Actor, verify_access_token};
use crate::models::access_token::AccessTokenPermission;
use crate::models::access_token::{TokenScope, TokenScopes};
use crate::models::authorization::Action;
use crate::repository::Repository;
use crate::services::Services;
use crate::services::authorization::AuthorizationService;

pub struct GqlContext {
pub repository: Repository,
pub services: Services,
pub actor: Option<Actor>,
pub site_id: Option<String>,
pub permission: Option<AccessTokenPermission>,
pub permission: Option<TokenScopes>,
}

impl GqlContext {
pub async fn from_request(
repository: Repository,
services: Services,
auth_header: Option<&str>,
requested_site: Option<&str>,
hmac_secret: &str,
) -> Self {
let mut actor = None;
Expand All @@ -24,12 +27,22 @@ impl GqlContext {

if let Some(header) = auth_header
&& let Some(token) = header.strip_prefix("Bearer ")
&& token.starts_with("vcms_site_")
&& (token.starts_with("vcms_site_") || token.starts_with("vcms_pat_"))
&& let Ok(auth_actor) = verify_access_token(token, &repository, hmac_secret).await
{
if let Actor::ApiKey(k) = &auth_actor {
site_id = Some(k.site_id.clone());
permission = Some(k.permission.clone());
permission = Some(k.scopes.clone());
} else if let Actor::PersonalToken(k) = &auth_actor {
permission = Some(k.scopes.clone());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
}
Expand All @@ -51,8 +64,12 @@ impl GqlContext {

pub fn require_read(&self) -> async_graphql::Result<()> {
match (&self.actor, &self.permission) {
(Some(Actor::ApiKey(_)), Some(_)) => Ok(()),
(Some(Actor::ApiKey(_)), None) => Err(async_graphql::Error::new(
(Some(Actor::ApiKey(_) | Actor::PersonalToken(_)), Some(scopes))
if scopes.contains(&TokenScope::ContentRead) =>
{
Ok(())
}
(Some(Actor::ApiKey(_) | Actor::PersonalToken(_)), None) => Err(async_graphql::Error::new(
"Access token does not have required permission",
)),
_ => Err(async_graphql::Error::new("Site token authentication required")),
Expand All @@ -61,10 +78,8 @@ impl GqlContext {

pub fn require_write(&self) -> async_graphql::Result<()> {
match &self.permission {
Some(AccessTokenPermission::Write) => Ok(()),
Some(AccessTokenPermission::Read) => {
Err(async_graphql::Error::new("Access token does not have write permission"))
}
Some(scopes) if scopes.contains(&TokenScope::ContentWrite) => Ok(()),
Some(_) => Err(async_graphql::Error::new("Access token does not have write permission")),
None => Err(async_graphql::Error::new("Site token authentication required")),
}
}
Expand Down
81 changes: 78 additions & 3 deletions apps/backend/src/handlers/access_token_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,14 +50,89 @@ 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(),
Err(e) => e.into_response(),
}
}

pub async fn list_personal_tokens(auth: AuthContext, Extension(services): Extension<Services>) -> 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<Repository>,
Extension(services): Extension<Services>,
Json(payload): Json<CreatePersonalAccessToken>,
) -> 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<String>,
Extension(services): Extension<Services>,
) -> 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,
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/src/handlers/backup_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub struct CreateBackupBody {
pub include_files: Option<bool>,
#[serde(default)]
pub encrypt: bool,
pub storage_profile_id: Option<String>,
}

#[derive(Deserialize)]
Expand All @@ -47,6 +48,7 @@ pub struct ScheduleBody {
pub include_files: Option<bool>,
pub encrypt: Option<bool>,
pub enabled: Option<bool>,
pub storage_profile_id: Option<String>,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading