Files
Ruben Fiszel d666e8431c feat: read-only flag on API tokens (#9144)
* feat: read-only flag on API tokens, orthogonal to scopes

Add a per-token `read_only` boolean set at creation time. When true, the
token can only call HTTP methods classified as Read (GET/HEAD/OPTIONS).
Mutating methods and job-run actions are rejected with 403, regardless of
which scopes are attached. Surfaced as a prominent toggle in the standard
token-creation flow and a discreet `2xs` toggle in MCP mode (where users
often want write access, so we don't bias them toward enabling it).

MCP enforcement: read-only tokens hide all script/flow/hub tools from
`list_tools` and only see endpoint tools whose method is GET, and the
runner rejects `call_tool` on anything mutating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: review fixes for read-only token flag

- Exempt /api/mcp/* and /mcp/* paths from the read-only middleware check.
  MCP transport runs over POST (streamable HTTP / SSE), so otherwise the
  middleware would 403 every MCP request before the runner could enforce
  read-only at the tool-call level.
- Tighten is_endpoint_read_only to GET only, matching the read_only_hint
  that create_endpoint_annotations actually emits.
- Add unit test for check_read_only_for_route covering GET/HEAD/OPTIONS,
  mutating methods, and run paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref to read-only-trigger-toggle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): make read-only toggle discreet in both modes

Match the MCP-mode treatment in standard mode: text-tertiary, 2xs, shared
"Read-only" label. The tooltip switches per mode so the explanation still
fits the context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): gate read-only toggle behind Limit token permissions

The read-only toggle now only shows when the user has limited the token's
scopes (standard mode) or in MCP mode (which always picks an MCP scope).
Turning the limit off also resets read-only so it doesn't silently stick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): hide incompatible MCP tools when read-only is on

When the read-only toggle is on in MCP mode:
- Endpoint badges and the custom-mode endpoint MultiSelect filter to GET.
- Already-selected non-GET endpoints are pruned from the scope.
- The scripts/flows preview is replaced with a note explaining they're
  hidden (the runner already rejects script/flow runs for read-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): place read-only toggle at top of limited scope area

The previous gate required at least one scope to be picked before the
read-only toggle appeared, which made it look missing while the user was
still building their scope list. Move the toggle inside ScopesPicker:
- Standard mode: sits directly under the "Limit token permissions" toggle
  whenever Limit is on, before the scope selector.
- MCP mode: sits at the top of the MCP scope block.

readOnly is now $bindable on ScopesPicker so CreateToken still owns the
value. The auto-reset on un-limit moves into ScopesPicker too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): nest read-only toggle inside the scope list card

Place the read-only toggle at the top of the scope list (between the
Selected Scopes summary and the bordered domain list) via a new optional
topSlot snippet on ScopeSelector. Keeps ScopeSelector decoupled from
read-only specifics; ScopesPicker fills the slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a

This commit updates the EE repository reference after PR #571 was merged in windmill-ee-private.

Previous ee-repo-ref: f53d26e6685dfd60bfa67686fbd7358169cfd130

New ee-repo-ref: 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a

Automated by sync-ee-ref workflow.

* fix: address CI review for read-only token flag

- P1 (Codex): narrow the MCP middleware exemption from "any /api/mcp/*"
  to just the streamable HTTP transport endpoints
  (/api/mcp/gateway, /api/mcp/w/{ws}/{mcp,sse,list_tools}). Without this,
  a read-only token could POST /api/mcp/gateway/oauth/server/approve and
  mint a follow-on non-read-only MCP token via the OAuth code/token
  exchange.
- P2 (Claude/cubic): fix test comment/assertion mismatch — the run-path
  assertion now exercises GET (which is what the RUN_PATH_ACTIONS
  elevation comment describes) in addition to POST. Add a regression
  assertion for /api/mcp/gateway/oauth/server/approve.
- P2 (cubic): short-circuit script/flow/hub-script/resource fetches in
  MCP list_tools when read_only is on — they would only be discarded
  below, so skipping the DB and resource fan-out is pure win.
- P2 (cubic): when scopes are pre-supplied via the CreateToken prop, the
  ScopesPicker isn't rendered, which previously hid the read-only
  toggle entirely. Render it next to the pre-supplied scopes display.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-13 15:26:21 +00:00

605 lines
18 KiB
Rust

/*!
* Integration tests for the native trigger system (Google).
*
* Tests cover 4 business-logic areas:
* 1. Resource path change — cleanup old path, recreate at new path
* 2. Config loading — workspace-level, instance-level, token update
* 3. Channel expiration renewal — should_renew_channel pure logic
* 4. Delete workspace integration — full cascade, cleanup preserves triggers, parse_stop_channel_params
*/
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_api_auth::ApiAuthed;
use windmill_common::variables::{build_crypt, encrypt};
use windmill_native_triggers::{
decrypt_oauth_data, delete_native_trigger, delete_workspace_integration,
get_workspace_integration,
google::{parse_stop_channel_params, should_renew_channel},
store_native_trigger, store_workspace_integration, NativeTriggerConfig, OAuthConfig,
ServiceName,
};
// ============================================================================
// Helpers
// ============================================================================
async fn insert_test_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
created_by, language, kind, lock)
VALUES ('test-workspace', $1, $2, '', '', 'def main(): pass',
'test-user', 'python3', 'script', '')",
)
.bind(hash)
.bind(path)
.execute(db)
.await?;
Ok(hash)
}
fn test_authed() -> ApiAuthed {
ApiAuthed {
email: "test@windmill.dev".to_string(),
username: "test-user".to_string(),
is_admin: true,
is_operator: false,
groups: vec!["all".to_string()],
folders: vec![],
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
}
}
/// Set up a complete workspace integration with account+variable+resource.
/// Returns (resource_path, account_id).
async fn setup_oauth_integration(
db: &Pool<Postgres>,
service_name: ServiceName,
resource_path: &str,
access_token: &str,
refresh_token: &str,
oauth_data_override: Option<serde_json::Value>,
) -> anyhow::Result<i32> {
// 1. Create account with is_workspace_integration=true
let account_id: i32 = sqlx::query_scalar!(
"INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)
VALUES ('test-workspace', $1, now() + interval '1 hour', $2, true)
RETURNING id",
service_name.as_str(),
refresh_token,
)
.fetch_one(db)
.await?;
// 2. Encrypt and create variable
let mc = build_crypt(db, "test-workspace").await?;
let encrypted = encrypt(&mc, access_token);
sqlx::query!(
"INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)
VALUES ('test-workspace', $1, $2, true, 'test oauth token', $3, true)",
resource_path,
encrypted,
account_id,
)
.execute(db)
.await?;
// 3. Create resource
let resource_value = json!({ "token": format!("$var:{}", resource_path) });
sqlx::query!(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', $1, $2, $3, '{}'::jsonb, 'test-user')",
resource_path,
resource_value,
service_name.resource_type(),
)
.execute(db)
.await?;
// 4. Store workspace integration with resource_path
let oauth_data = oauth_data_override.unwrap_or_else(|| {
json!({
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"base_url": "https://example.com",
"resource_path": resource_path,
})
});
let authed = test_authed();
let mut tx = db.begin().await?;
store_workspace_integration(
&mut *tx,
&authed,
"test-workspace",
service_name,
oauth_data,
Some(resource_path),
)
.await?;
tx.commit().await?;
Ok(account_id)
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
// ============================================================================
// 1. Resource Path Change
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_resource_path_change(db: Pool<Postgres>) -> anyhow::Result<()> {
let path_a = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
path_a,
"token-a",
"refresh-a",
None,
)
.await?;
// Verify decrypt works at path A
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "token-a");
// Cleanup old path
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// Recreate at path B
let path_b = "u/test-user/native_gworkspace_v2";
setup_oauth_integration(
&db,
ServiceName::Google,
path_b,
"token-b",
"refresh-b",
None,
)
.await?;
// Path A resources should be gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
path_a,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0, "variable at old path should be deleted");
let res_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
path_a,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(res_count, 0, "resource at old path should be deleted");
// Path B should work
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "token-b");
assert_eq!(config.refresh_token.as_deref(), Some("refresh-b"));
Ok(())
}
// ============================================================================
// 2. Config Loading — workspace vs instance + token update
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_decrypt_workspace_level(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"ws-access-token",
"ws-refresh-token",
None,
)
.await?;
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "ws-access-token");
assert_eq!(config.refresh_token.as_deref(), Some("ws-refresh-token"));
assert_eq!(config.client_id, "test-client-id");
assert_eq!(config.client_secret, "test-client-secret");
assert_eq!(config.base_url, "https://example.com");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_decrypt_instance_level(db: Pool<Postgres>) -> anyhow::Result<()> {
// Insert instance-level credentials into global_settings
sqlx::query!(
"INSERT INTO global_settings (name, value) VALUES ('oauths', $1)
ON CONFLICT (name) DO UPDATE SET value = $1",
json!({
"gworkspace": {
"id": "instance-client-id",
"secret": "instance-client-secret"
}
}),
)
.execute(&db)
.await?;
let resource_path = "u/test-user/native_gworkspace";
let oauth_data = json!({
"instance_shared": true,
"base_url": "https://accounts.google.com",
"resource_path": resource_path,
});
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"inst-access-token",
"inst-refresh-token",
Some(oauth_data),
)
.await?;
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.client_id, "instance-client-id");
assert_eq!(config.client_secret, "instance-client-secret");
assert_eq!(config.access_token, "inst-access-token");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
let account_id = setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"old-access-token",
"old-refresh-token",
None,
)
.await?;
// Verify old tokens
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "old-access-token");
// Simulate token refresh: update variable + account
let mc = build_crypt(&db, "test-workspace").await?;
let new_encrypted = encrypt(&mc, "new-access-token");
sqlx::query!(
"UPDATE variable SET value = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
new_encrypted,
resource_path,
)
.execute(&db)
.await?;
sqlx::query!(
"UPDATE account SET refresh_token = $1 WHERE workspace_id = 'test-workspace' AND id = $2",
"new-refresh-token",
account_id,
)
.execute(&db)
.await?;
// Verify new tokens
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "new-access-token");
assert_eq!(config.refresh_token.as_deref(), Some("new-refresh-token"));
Ok(())
}
// ============================================================================
// 3. Channel Expiration Renewal — should_renew_channel
// ============================================================================
#[test]
fn test_should_renew_drive_channel_expired() {
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() - 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_drive_channel_within_window() {
// 30 minutes remaining — within the 1-hour Drive renewal window
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() + 30 * 60 * 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_drive_channel_not_yet() {
// 2 hours remaining — outside the 1-hour Drive renewal window
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() + 2 * 60 * 60 * 1000).to_string(),
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_calendar_channel_within_window() {
// 12 hours remaining — within the 1-day Calendar renewal window
let config = json!({
"triggerType": "calendar",
"expiration": (now_ms() + 12 * 60 * 60 * 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_calendar_channel_not_yet() {
// 2 days remaining — outside the 1-day Calendar renewal window
let config = json!({
"triggerType": "calendar",
"expiration": (now_ms() + 2 * 24 * 60 * 60 * 1000).to_string(),
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_channel_zero_expiration() {
let config = json!({
"triggerType": "drive",
"expiration": "0",
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_channel_missing_fields() {
assert!(!should_renew_channel(&json!({})));
}
// ============================================================================
// 4. Delete Workspace Integration
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
let account_id = setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"token",
"refresh",
None,
)
.await?;
// Add a native trigger linked to this integration
insert_test_script(&db, "f/test/handler").await?;
let trigger_config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Google,
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
None,
)
.await?;
// Step 1: Delete triggers
let deleted =
delete_native_trigger(&db, "test-workspace", ServiceName::Google, "ext-1").await?;
assert!(deleted);
// Step 2: Cleanup OAuth resources
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// Step 3: Delete workspace integration
let mut tx = db.begin().await?;
let deleted =
delete_workspace_integration(&mut *tx, "test-workspace", ServiceName::Google).await?;
tx.commit().await?;
assert!(deleted);
// Verify everything is gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0);
let acc_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM account WHERE workspace_id = 'test-workspace' AND id = $1",
account_id,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(acc_count, 0);
let res_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(res_count, 0);
assert!(
get_workspace_integration(&db, "test-workspace", ServiceName::Google)
.await
.is_err()
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"token",
"refresh",
None,
)
.await?;
// Create a trigger
insert_test_script(&db, "f/test/handler").await?;
let trigger_config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Google,
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
None,
)
.await?;
// Cleanup OAuth only — should NOT remove the trigger
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// OAuth resources gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0);
// Trigger still exists
let trigger_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM native_trigger WHERE workspace_id = 'test-workspace' AND service_name = 'google'"
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(trigger_count, 1, "trigger should survive OAuth cleanup");
Ok(())
}
// --- parse_stop_channel_params ---
#[test]
fn test_parse_stop_channel_params_drive() {
let config = json!({
"triggerType": "drive",
"googleChannelId": "chan-abc",
"googleResourceId": "res-123",
});
let (channel_id, resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(channel_id.as_deref(), Some("chan-abc"));
assert_eq!(resource_id, "res-123");
assert!(
url.contains("googleapis.com/drive/v3/channels/stop"),
"url={}",
url
);
}
#[test]
fn test_parse_stop_channel_params_calendar() {
let config = json!({
"triggerType": "calendar",
"googleChannelId": "chan-xyz",
"googleResourceId": "res-456",
});
let (channel_id, resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(channel_id.as_deref(), Some("chan-xyz"));
assert_eq!(resource_id, "res-456");
assert!(
url.contains("googleapis.com/calendar/v3/channels/stop"),
"url={}",
url
);
}
#[test]
fn test_parse_stop_channel_params_default() {
// Missing triggerType defaults to Drive; missing googleChannelId yields None.
let config = json!({ "googleResourceId": "res-789" });
let (channel_id, resource_id, url) = parse_stop_channel_params(&config);
assert!(channel_id.is_none());
assert_eq!(resource_id, "res-789");
assert!(url.contains("drive/v3/channels/stop"), "url={}", url);
}
#[test]
fn test_parse_stop_channel_params_missing_resource_id() {
let config = json!({ "triggerType": "drive" });
let (channel_id, resource_id, _url) = parse_stop_channel_params(&config);
assert!(channel_id.is_none());
assert_eq!(resource_id, "");
}