diff --git a/.claude/hooks/.symlink-manifest b/.claude/hooks/.symlink-manifest new file mode 100644 index 0000000000..8554e20bc2 --- /dev/null +++ b/.claude/hooks/.symlink-manifest @@ -0,0 +1,57 @@ +/home/rfiszel/windmill/backend/windmill-common/src/otel_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/otel_ee.rs +/home/rfiszel/windmill/backend/windmill-common/src/secret_backend/vault_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/secret_backend/vault_ee.rs +/home/rfiszel/windmill/backend/windmill-common/src/oidc_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/oidc_ee.rs +/home/rfiszel/windmill/backend/windmill-common/src/db_iam_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/db_iam_ee.rs +/home/rfiszel/windmill/backend/windmill-common/src/job_s3_helpers_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/job_s3_helpers_ee.rs +/home/rfiszel/windmill/backend/windmill-common/src/email_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/email_ee.rs +/home/rfiszel/windmill/backend/windmill-common/src/teams_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/teams_ee.rs +/home/rfiszel/windmill/backend/windmill-common/src/stats_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/stats_ee.rs +/home/rfiszel/windmill/backend/windmill-common/src/git_sync_ee.rs|/home/rfiszel/windmill-ee-private/windmill-common/src/git_sync_ee.rs +/home/rfiszel/windmill/backend/windmill-indexer/src/indexer_ee.rs|/home/rfiszel/windmill-ee-private/windmill-indexer/src/indexer_ee.rs +/home/rfiszel/windmill/backend/windmill-indexer/src/service_logs_ee.rs|/home/rfiszel/windmill-ee-private/windmill-indexer/src/service_logs_ee.rs +/home/rfiszel/windmill/backend/windmill-indexer/src/completed_runs_ee.rs|/home/rfiszel/windmill-ee-private/windmill-indexer/src/completed_runs_ee.rs +/home/rfiszel/windmill/backend/windmill-git-sync/src/git_sync_ee.rs|/home/rfiszel/windmill-ee-private/windmill-git-sync/src/git_sync_ee.rs +/home/rfiszel/windmill/backend/windmill-autoscaling/src/kubernetes_integration_ee.rs|/home/rfiszel/windmill-ee-private/windmill-autoscaling/src/kubernetes_integration_ee.rs +/home/rfiszel/windmill/backend/windmill-autoscaling/src/autoscaling_ee.rs|/home/rfiszel/windmill-ee-private/windmill-autoscaling/src/autoscaling_ee.rs +/home/rfiszel/windmill/backend/src/windows_service_ee.rs|/home/rfiszel/windmill-ee-private/src/windows_service_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/s3_proxy_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/s3_proxy_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/teams_approvals_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/teams_approvals_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/email/handler_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/email/handler_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/email/mod_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/email/mod_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/nats/handler_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/nats/handler_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/nats/mod_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/nats/mod_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/nats/listener_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/nats/listener_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/sqs/handler_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/sqs/handler_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/sqs/mod_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/sqs/mod_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/sqs/listener_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/sqs/listener_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/gcp/handler_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/gcp/handler_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/gcp/mod_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/gcp/mod_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/gcp/listener_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/gcp/listener_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/kafka/handler_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/kafka/handler_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/kafka/mod_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/kafka/mod_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/triggers/kafka/listener_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/triggers/kafka/listener_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/oidc_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/oidc_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/teams_cache_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/teams_cache_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/mcp_oauth_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/mcp_oauth_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/workspaces_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/workspaces_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/stripe_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/stripe_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/apps_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/apps_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/scim_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/scim_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/job_helpers_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/job_helpers_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/teams_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/teams_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/inkeep_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/inkeep_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/oauth2_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/oauth2_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/users_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/users_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/azure_proxy_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/azure_proxy_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/indexer_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/indexer_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/smtp_server_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/smtp_server_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/agent_workers_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/agent_workers_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/git_sync_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/git_sync_ee.rs +/home/rfiszel/windmill/backend/windmill-api/src/saml_ee.rs|/home/rfiszel/windmill-ee-private/windmill-api/src/saml_ee.rs +/home/rfiszel/windmill/backend/windmill-queue/src/jobs_ee.rs|/home/rfiszel/windmill-ee-private/windmill-queue/src/jobs_ee.rs +/home/rfiszel/windmill/backend/windmill-worker/src/otel_ee.rs|/home/rfiszel/windmill-ee-private/windmill-worker/src/otel_ee.rs +/home/rfiszel/windmill/backend/windmill-worker/src/otel_tracing_proxy_ee.rs|/home/rfiszel/windmill-ee-private/windmill-worker/src/otel_tracing_proxy_ee.rs +/home/rfiszel/windmill/backend/windmill-worker/src/dedicated_worker_ee.rs|/home/rfiszel/windmill-ee-private/windmill-worker/src/dedicated_worker_ee.rs +/home/rfiszel/windmill/backend/windmill-worker/src/job_logger_ee.rs|/home/rfiszel/windmill-ee-private/windmill-worker/src/job_logger_ee.rs +/home/rfiszel/windmill/backend/windmill-worker/src/memory_ee.rs|/home/rfiszel/windmill-ee-private/windmill-worker/src/memory_ee.rs +/home/rfiszel/windmill/backend/windmill-audit/src/audit_ee.rs|/home/rfiszel/windmill-ee-private/windmill-audit/src/audit_ee.rs diff --git a/backend/tests/fixtures/permissions_test.sql b/backend/tests/fixtures/permissions_test.sql new file mode 100644 index 0000000000..a9006f5233 --- /dev/null +++ b/backend/tests/fixtures/permissions_test.sql @@ -0,0 +1,238 @@ +-- Permissions test fixture +-- This sets up a comprehensive permission testing scenario + +-- ============================================ +-- USERS +-- ============================================ + +-- Admin user (may already exist from base.sql, use ON CONFLICT) +INSERT INTO usr (workspace_id, email, username, is_admin, role) +VALUES ('test-workspace', 'admin@windmill.dev', 'admin', true, 'Admin') +ON CONFLICT (workspace_id, username) DO NOTHING; + +-- Regular users (non-admin) +INSERT INTO usr (workspace_id, email, username, is_admin, role) +VALUES + ('test-workspace', 'alice@windmill.dev', 'alice', false, 'Developer'), + ('test-workspace', 'bob@windmill.dev', 'bob', false, 'Developer'), + ('test-workspace', 'charlie@windmill.dev', 'charlie', false, 'Developer'); + +-- Operator user (can execute but cannot create/update scripts, flows, apps) +INSERT INTO usr (workspace_id, email, username, is_admin, operator, role) +VALUES + ('test-workspace', 'operator@windmill.dev', 'operator', false, true, 'Operator'); + +-- Add users to password table (use ON CONFLICT since admin may exist from base.sql) +INSERT INTO password (email, password_hash, login_type, super_admin, verified, name) +VALUES + ('admin@windmill.dev', 'dummy_hash', 'password', false, true, 'Admin User'), + ('alice@windmill.dev', 'dummy_hash', 'password', false, true, 'Alice'), + ('bob@windmill.dev', 'dummy_hash', 'password', false, true, 'Bob'), + ('charlie@windmill.dev', 'dummy_hash', 'password', false, true, 'Charlie'), + ('operator@windmill.dev', 'dummy_hash', 'password', false, true, 'Operator') +ON CONFLICT (email) DO NOTHING; + +-- ============================================ +-- TOKENS for authentication +-- ============================================ + +-- Tokens associated with emails (workspace-scoped) +-- The auth system will look up the user by email in the usr table +-- Note: tokens must be at least 10 characters (TOKEN_PREFIX_LEN) +INSERT INTO token (token, email, label, super_admin, owner, workspace_id) +VALUES + ('ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), + ('ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), + ('BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), + ('CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), + ('OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); + +-- ============================================ +-- GROUPS +-- ============================================ + +-- 'developers' group - Charlie is a member +INSERT INTO group_ (workspace_id, name, summary, extra_perms) +VALUES ('test-workspace', 'developers', 'Developer group', '{}'); + +-- 'editors' group - Bob is a member (has write access to some folders) +INSERT INTO group_ (workspace_id, name, summary, extra_perms) +VALUES ('test-workspace', 'editors', 'Editor group', '{}'); + +-- Group memberships +INSERT INTO usr_to_group (workspace_id, group_, usr) +VALUES + ('test-workspace', 'developers', 'charlie'), + ('test-workspace', 'editors', 'bob'); + +-- ============================================ +-- FOLDERS +-- ============================================ + +-- 'shared' folder: +-- - Alice has read-only access (extra_perms: u/alice -> false) +-- - Bob has write access (extra_perms: u/bob -> true) +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'shared', 'Shared Folder', '{"u/admin"}', + '{"u/alice": false, "u/bob": true}', 'admin'); + +-- 'team' folder: +-- - 'developers' group has read-only access +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'team', 'Team Folder', '{"u/admin"}', + '{"g/developers": false}', 'admin'); + +-- 'editable' folder: +-- - 'editors' group has write access +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'editable', 'Editable Folder', '{"u/admin"}', + '{"g/editors": true}', 'admin'); + +-- 'admin_only' folder: +-- - Only admin has access (but we'll add item-level perms for specific items) +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'admin_only', 'Admin Only Folder', '{"u/admin"}', + '{}', 'admin'); + +-- 'alice_owned' folder: +-- - Alice is an owner (owners must also be in extra_perms for get_folders_for_user to find them) +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'alice_owned', 'Alice Owned Folder', '{"u/alice"}', + '{"u/alice": true}', 'alice'); + +-- ============================================ +-- SCRIPTS +-- ============================================ + +-- Alice's personal script +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 1001, 'u/alice/my_script', + 'export function main() { return "alice script"; }', + 'deno', 'script', 'alice', '{}', 'Alice script', '', '', '{}'); + +-- Bob's personal script +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 1002, 'u/bob/my_script', + 'export function main() { return "bob script"; }', + 'deno', 'script', 'bob', '{}', 'Bob script', '', '', '{}'); + +-- Script in shared folder (accessible by Alice read-only, Bob write) +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 1003, 'f/shared/public_script', + 'export function main() { return "public"; }', + 'deno', 'script', 'admin', '{}', 'Public script', '', '', '{}'); + +-- Script in team folder (accessible by developers group) +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 1004, 'f/team/team_script', + 'export function main() { return "team"; }', + 'deno', 'script', 'admin', '{}', 'Team script', '', '', '{}'); + +-- Script in admin_only folder but with explicit share to Alice +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 1005, 'f/admin_only/shared_with_alice', + 'export function main() { return "shared"; }', + 'deno', 'script', 'admin', '{}', 'Shared with Alice', '', '', + '{"u/alice": false}'); + +-- Script in alice_owned folder +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 1006, 'f/alice_owned/owner_script', + 'export function main() { return "owner"; }', + 'deno', 'script', 'alice', '{}', 'Owner script', '', '', '{}'); + +-- Alice's script with extra_perms sharing to Bob (read-only) +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 1007, 'u/alice/extra_shared_script', + 'export function main() { return "extra shared"; }', + 'deno', 'script', 'alice', '{}', 'Extra shared script', '', '', + '{"u/bob": false}'); + +-- ============================================ +-- FLOWS (with flow_versions) +-- ============================================ + +-- Create flows first (without versions) +INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms) +VALUES + ('test-workspace', 'u/alice/my_flow', 'Alice flow', '', '{"modules": []}', 'alice', NOW(), '{}', '{}'), + ('test-workspace', 'u/bob/my_flow', 'Bob flow', '', '{"modules": []}', 'bob', NOW(), '{}', '{}'), + ('test-workspace', 'f/shared/shared_flow', 'Shared flow', '', '{"modules": []}', 'admin', NOW(), '{}', '{}'); + +-- Create flow versions +INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at) +VALUES + (1001, 'test-workspace', 'u/alice/my_flow', '{"modules": []}', '{}', 'alice', NOW()), + (1002, 'test-workspace', 'u/bob/my_flow', '{"modules": []}', '{}', 'bob', NOW()), + (1003, 'test-workspace', 'f/shared/shared_flow', '{"modules": []}', '{}', 'admin', NOW()); + +-- Update flows with version references +UPDATE flow SET versions = ARRAY[1001::bigint] WHERE path = 'u/alice/my_flow' AND workspace_id = 'test-workspace'; +UPDATE flow SET versions = ARRAY[1002::bigint] WHERE path = 'u/bob/my_flow' AND workspace_id = 'test-workspace'; +UPDATE flow SET versions = ARRAY[1003::bigint] WHERE path = 'f/shared/shared_flow' AND workspace_id = 'test-workspace'; + +-- ============================================ +-- RESOURCES +-- ============================================ + +-- Alice's personal resource +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ('test-workspace', 'u/alice/my_resource', '{"key": "alice_value"}', + 'Alice resource', 'object', '{}', 'alice'); + +-- Bob's personal resource +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ('test-workspace', 'u/bob/my_resource', '{"key": "bob_value"}', + 'Bob resource', 'object', '{}', 'bob'); + +-- ============================================ +-- VARIABLES +-- ============================================ + +-- Alice's personal variable (non-secret for testing permissions, not encryption) +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'u/alice/my_variable', 'alice_value', false, + 'Alice variable', '{}'); + +-- Bob's personal variable (non-secret for testing permissions, not encryption) +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'u/bob/my_variable', 'bob_value', false, + 'Bob variable', '{}'); + +-- ============================================ +-- SCHEDULES +-- ============================================ + +-- Alice's personal schedule +INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, is_flow, email, timezone, extra_perms) +VALUES ('test-workspace', 'u/alice/my_schedule', 'alice', NOW(), '0 * * * *', false, + 'u/alice/my_script', '{}', false, 'alice@windmill.dev', 'UTC', '{}'); + +-- Bob's personal schedule +INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, is_flow, email, timezone, extra_perms) +VALUES ('test-workspace', 'u/bob/my_schedule', 'bob', NOW(), '0 * * * *', false, + 'u/bob/my_script', '{}', false, 'bob@windmill.dev', 'UTC', '{}'); + +-- ============================================ +-- APPS (with app_versions) +-- ============================================ + +-- Alice's personal app +INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms) +VALUES (2001, 'test-workspace', 'u/alice/my_app', 'Alice app', '{}', + '{"on_behalf_of": "u/alice", "on_behalf_of_email": "alice@windmill.dev", "execution_mode": "viewer"}', '{}'); + +-- Shared folder app +INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms) +VALUES (2002, 'test-workspace', 'f/shared/shared_app', 'Shared app', '{}', + '{"on_behalf_of": "u/admin", "on_behalf_of_email": "admin@windmill.dev", "execution_mode": "viewer"}', '{}'); + +-- Create app versions +INSERT INTO app_version (id, app_id, value, created_by, created_at) +VALUES + (2001, 2001, '{"grid": []}', 'alice', NOW()), + (2002, 2002, '{"grid": []}', 'admin', NOW()); + +-- Update apps with version references +UPDATE app SET versions = ARRAY[2001::bigint] WHERE id = 2001; +UPDATE app SET versions = ARRAY[2002::bigint] WHERE id = 2002; diff --git a/backend/tests/permissions.rs b/backend/tests/permissions.rs new file mode 100644 index 0000000000..0b1604fbb1 --- /dev/null +++ b/backend/tests/permissions.rs @@ -0,0 +1,776 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +mod common; +use common::*; + +/// Helper to create a client authenticated as a specific user +async fn create_client_for_user(_port: u16, token: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() +} + +/// Test helper to check if a GET request succeeds +async fn can_read(client: &reqwest::Client, url: &str) -> bool { + let resp = client.get(url).send().await.unwrap(); + resp.status().is_success() +} + +/// Test helper to check if a POST request succeeds (for write operations) +async fn can_write(client: &reqwest::Client, url: &str, body: serde_json::Value) -> bool { + let resp = client.post(url).json(&body).send().await.unwrap(); + let status = resp.status(); + // 200, 201, 204 are success, 403 is forbidden, 401 is unauthorized + status.is_success() +} + +/// Comprehensive permissions test that verifies: +/// - Non-admin users can only access their user-space (u/username) +/// - Non-admin users can only access folders they have been given access to +/// - Read access when given read permission +/// - Write access when given write permission +/// - Access through direct permissions (u/username) +/// - Access through group memberships (g/groupname) +/// +/// This test is ignored in CI because it requires a full Windmill setup +/// and takes longer to run. Run it locally with: +/// `cargo test --features deno_core permissions -- --ignored` +#[ignore] +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "permissions_test"))] +async fn test_permissions_exhaustive(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{}/api", port); + + // Create clients for each user (tokens must be at least 10 chars) + let admin_client = create_client_for_user(port, "ADMIN_TOKEN_TEST").await; + let alice_client = create_client_for_user(port, "ALICE_TOKEN_TEST").await; + let bob_client = create_client_for_user(port, "BOB_TOKEN_TEST12").await; + let charlie_client = create_client_for_user(port, "CHARLIE_TOKEN_01").await; + + // ============================================ + // TEST 1: User namespace access (u/username) + // ============================================ + + // Alice should be able to read her own scripts + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await, + "Alice should be able to read her own script" + ); + + // Alice should NOT be able to read Bob's scripts + assert!( + !can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/bob/my_script") + ) + .await, + "Alice should NOT be able to read Bob's script" + ); + + // Bob should be able to read his own scripts + assert!( + can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/bob/my_script") + ) + .await, + "Bob should be able to read his own script" + ); + + // Bob should NOT be able to read Alice's scripts + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await, + "Bob should NOT be able to read Alice's script" + ); + + // Admin should be able to read any script + assert!( + can_read( + &admin_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await, + "Admin should be able to read Alice's script" + ); + assert!( + can_read( + &admin_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/bob/my_script") + ) + .await, + "Admin should be able to read Bob's script" + ); + + // ============================================ + // TEST 2: Folder access without permissions + // ============================================ + + // Charlie has no folder permissions, should not be able to access folder items + assert!( + !can_read( + &charlie_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/f/shared/public_script") + ) + .await, + "Charlie should NOT be able to read shared folder script without permissions" + ); + + // ============================================ + // TEST 3: Folder read-only access + // ============================================ + + // Alice has read-only access to 'shared' folder + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/f/shared/public_script") + ) + .await, + "Alice should be able to read shared folder script (read permission)" + ); + + // Alice should NOT be able to create/update scripts in shared folder (read-only) + let create_script_body = json!({ + "path": "f/shared/alice_new_script", + "content": "export function main() { return 'test'; }", + "language": "deno", + "summary": "Test script", + "description": "" + }); + assert!( + !can_write( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/create"), + create_script_body.clone() + ) + .await, + "Alice should NOT be able to create scripts in shared folder (read-only)" + ); + + // ============================================ + // TEST 4: Folder write access + // ============================================ + + // Bob has write access to 'shared' folder + assert!( + can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/f/shared/public_script") + ) + .await, + "Bob should be able to read shared folder script (write permission includes read)" + ); + + // Bob should be able to create scripts in shared folder (write access) + let create_script_body = json!({ + "path": "f/shared/bob_new_script", + "content": "export function main() { return 'test'; }", + "language": "deno", + "summary": "Test script", + "description": "", + "schema": {} + }); + assert!( + can_write( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/create"), + create_script_body + ) + .await, + "Bob should be able to create scripts in shared folder (write permission)" + ); + + // ============================================ + // TEST 5: Group-based folder access + // ============================================ + + // Charlie is in the 'developers' group which has read access to 'team' folder + // First, let's verify Charlie has no direct access + // Then verify he gets access through group membership + + assert!( + can_read( + &charlie_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/f/team/team_script") + ) + .await, + "Charlie should be able to read team folder script via group membership" + ); + + // Charlie's group only has read access, so he shouldn't be able to write + let create_team_script = json!({ + "path": "f/team/charlie_script", + "content": "export function main() { return 'test'; }", + "language": "deno", + "summary": "Test script", + "description": "", + "schema": {} + }); + assert!( + !can_write( + &charlie_client, + &format!("{base_url}/w/test-workspace/scripts/create"), + create_team_script + ) + .await, + "Charlie should NOT be able to create scripts in team folder (group has read-only)" + ); + + // ============================================ + // TEST 6: Resource permissions + // ============================================ + + // Alice should be able to read her own resources + // Resources use /resources/get/{path} (no /p/ prefix) + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource") + ) + .await, + "Alice should be able to read her own resource" + ); + + // Alice should NOT be able to read Bob's resources + assert!( + !can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/resources/get/u/bob/my_resource") + ) + .await, + "Alice should NOT be able to read Bob's resource" + ); + + // ============================================ + // TEST 7: Variable permissions + // ============================================ + + // Alice should be able to read her own variables + // Variables use /variables/get/{path} (no /p/ prefix) + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable") + ) + .await, + "Alice should be able to read her own variable" + ); + + // Alice should NOT be able to read Bob's variables + assert!( + !can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/variables/get/u/bob/my_variable") + ) + .await, + "Alice should NOT be able to read Bob's variable" + ); + + // ============================================ + // TEST 8: Flow permissions + // ============================================ + + // Alice should be able to read flows in folders she has access to + // Flows use /flows/get/{path} (no /p/ prefix) + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/flows/get/f/shared/shared_flow") + ) + .await, + "Alice should be able to read shared folder flow" + ); + + // Charlie should NOT be able to read flows in shared folder (no access) + assert!( + !can_read( + &charlie_client, + &format!("{base_url}/w/test-workspace/flows/get/f/shared/shared_flow") + ) + .await, + "Charlie should NOT be able to read shared folder flow (no permission)" + ); + + // ============================================ + // TEST 9: Direct extra_perms grants + // ============================================ + + // Alice has direct read permission on a specific script owned by admin + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/f/admin_only/shared_with_alice") + ) + .await, + "Alice should be able to read script explicitly shared with her" + ); + + // Bob should NOT be able to read that script (not shared with him) + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/f/admin_only/shared_with_alice") + ) + .await, + "Bob should NOT be able to read script only shared with Alice" + ); + + // ============================================ + // TEST 10: Write via group membership + // ============================================ + + // Bob is in the 'editors' group which has write access to 'editable' folder + let create_editable_script = json!({ + "path": "f/editable/bob_editor_script", + "content": "export function main() { return 'test'; }", + "language": "deno", + "summary": "Test script", + "description": "", + "schema": {} + }); + assert!( + can_write( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/create"), + create_editable_script + ) + .await, + "Bob should be able to create scripts in editable folder via group write access" + ); + + // ============================================ + // TEST 11: Schedule permissions + // ============================================ + + // Schedules follow the same permission model + // Schedules use /schedules/get/{path} (no /p/ prefix) + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule") + ) + .await, + "Alice should be able to read her own schedule" + ); + + assert!( + !can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/schedules/get/u/bob/my_schedule") + ) + .await, + "Alice should NOT be able to read Bob's schedule" + ); + + // ============================================ + // TEST 12: App permissions + // ============================================ + + // Alice should be able to read apps in shared folder + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/apps/get/p/f/shared/shared_app") + ) + .await, + "Alice should be able to read shared folder app" + ); + + // Charlie should NOT be able to read apps in shared folder + assert!( + !can_read( + &charlie_client, + &format!("{base_url}/w/test-workspace/apps/get/p/f/shared/shared_app") + ) + .await, + "Charlie should NOT be able to read shared folder app" + ); + + // ============================================ + // TEST 13: Operator permissions + // ============================================ + // Note: Operators can execute but have limited management permissions + // This is tested separately if needed + + // ============================================ + // TEST 14: Folder owner permissions + // ============================================ + + // Alice is an owner of the 'alice_owned' folder + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/f/alice_owned/owner_script") + ) + .await, + "Alice should be able to read scripts in folder she owns" + ); + + // Alice should be able to write to her owned folder + let create_owned_script = json!({ + "path": "f/alice_owned/new_owner_script", + "content": "export function main() { return 'test'; }", + "language": "deno", + "summary": "Test script", + "description": "", + "schema": {} + }); + assert!( + can_write( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/create"), + create_owned_script + ) + .await, + "Alice should be able to create scripts in folder she owns" + ); + + // ============================================ + // TEST 15: Cross-user script with extra_perms + // ============================================ + + // Verify that giving someone read-only permission doesn't give write + // Bob has read-only permission on alice's extra_shared_script + assert!( + can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/extra_shared_script") + ) + .await, + "Bob should be able to read Alice's script shared with him" + ); + + // But Bob should not be able to archive/delete it + let archive_resp = bob_client + .post(&format!( + "{base_url}/w/test-workspace/scripts/archive/p/u/alice/extra_shared_script" + )) + .send() + .await + .unwrap(); + assert!( + !archive_resp.status().is_success(), + "Bob should NOT be able to archive Alice's script (read-only permission)" + ); + + Ok(()) +} + +/// Additional test for verifying group permission inheritance +#[ignore] +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "permissions_test"))] +async fn test_group_permission_inheritance(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{}/api", port); + + let charlie_client = create_client_for_user(port, "CHARLIE_TOKEN_01").await; + + // Charlie is in 'developers' group + // 'developers' group has read access to 'team' folder + + // Test 1: Charlie can read from team folder via group + // Scripts use /scripts/get/p/{path} + assert!( + can_read( + &charlie_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/f/team/team_script") + ) + .await, + "Charlie should read team folder via group" + ); + + // Test 2: Add Charlie to 'editors' group (which has write to 'editable') + // This should give him write access + sqlx::query!( + "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'editors', 'charlie')" + ) + .execute(&db) + .await?; + + // Note: The AUTH_CACHE in windmill_api::auth caches authentication results + // for (workspace_id, token) tuples. Since we can't easily clear it from tests, + // we use a different token or wait for cache expiry. For this test, we create + // a new token for Charlie. + sqlx::query!( + "INSERT INTO token (token, email, label, super_admin, owner, workspace_id) + VALUES ('CHARLIE_TOKEN_NEW', 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')" + ) + .execute(&db) + .await?; + + // Use the new token to get fresh permissions + let charlie_client_fresh = create_client_for_user(port, "CHARLIE_TOKEN_NEW").await; + + let create_script = json!({ + "path": "f/editable/charlie_editor_script", + "content": "export function main() { return 'from charlie'; }", + "language": "deno", + "summary": "Charlie's editor script", + "description": "", + "schema": {} + }); + + assert!( + can_write( + &charlie_client_fresh, + &format!("{base_url}/w/test-workspace/scripts/create"), + create_script + ) + .await, + "Charlie should be able to write to editable folder after joining editors group" + ); + + Ok(()) +} + +/// Test that permissions work correctly for all item types +#[ignore] +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "permissions_test"))] +async fn test_all_item_types_permissions(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{}/api", port); + + let alice_client = create_client_for_user(port, "ALICE_TOKEN_TEST").await; + let bob_client = create_client_for_user(port, "BOB_TOKEN_TEST12").await; + + // Test Scripts - uses /scripts/get/p/{path} + assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script")).await); + assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script")).await); + + // Test Flows - uses /flows/get/{path} (no /p/) + assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow")).await); + assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow")).await); + + // Test Resources - uses /resources/get/{path} (no /p/) + assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource")).await); + assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource")).await); + + // Test Variables - uses /variables/get/{path} (no /p/) + assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable")).await); + assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable")).await); + + // Test Schedules - uses /schedules/get/{path} (no /p/) + assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule")).await); + assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule")).await); + + // Test Apps - uses /apps/get/p/{path} + assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app")).await); + assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app")).await); + + Ok(()) +} + +/// Test that operators cannot create or update scripts, flows, and apps +/// Operators have limited permissions - they can execute but cannot manage resources +#[ignore] +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "permissions_test"))] +async fn test_operator_cannot_create_update(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{}/api", port); + + // Create operator client + let operator_client = create_client_for_user(port, "OPERATOR_TOKEN_1").await; + + // ============================================ + // TEST: Operator cannot create scripts + // ============================================ + let create_script = json!({ + "path": "u/operator/test_script", + "content": "export function main() { return 'test'; }", + "language": "deno", + "summary": "Test script", + "description": "", + "schema": {} + }); + assert!( + !can_write( + &operator_client, + &format!("{base_url}/w/test-workspace/scripts/create"), + create_script + ) + .await, + "Operator should NOT be able to create scripts" + ); + + // ============================================ + // TEST: Operator cannot create flows + // ============================================ + let create_flow = json!({ + "path": "u/operator/test_flow", + "summary": "Test flow", + "description": "", + "value": {"modules": []}, + "schema": {} + }); + assert!( + !can_write( + &operator_client, + &format!("{base_url}/w/test-workspace/flows/create"), + create_flow + ) + .await, + "Operator should NOT be able to create flows" + ); + + // ============================================ + // TEST: Operator cannot create apps + // ============================================ + let create_app = json!({ + "path": "u/operator/test_app", + "summary": "Test app", + "value": {"grid": []}, + "policy": { + "on_behalf_of": "u/operator", + "on_behalf_of_email": "operator@windmill.dev", + "execution_mode": "viewer" + } + }); + assert!( + !can_write( + &operator_client, + &format!("{base_url}/w/test-workspace/apps/create"), + create_app + ) + .await, + "Operator should NOT be able to create apps" + ); + + // ============================================ + // TEST: Operator cannot archive scripts + // ============================================ + + // Insert a script in operator's namespace using direct DB access + sqlx::query!( + r#"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms) + VALUES ('test-workspace', 3001, 'u/operator/existing_script', 'export function main() { return "original"; }', 'deno', 'script', 'admin', '{}', 'Existing script', '', '', '{}')"# + ) + .execute(&db) + .await?; + + // Operator tries to archive this script + let archive_resp = operator_client + .post(&format!( + "{base_url}/w/test-workspace/scripts/archive/p/u/operator/existing_script" + )) + .send() + .await + .unwrap(); + assert!( + !archive_resp.status().is_success(), + "Operator should NOT be able to archive scripts" + ); + + // ============================================ + // TEST: Operator cannot update flows + // ============================================ + + // Insert a flow in operator's namespace + sqlx::query!( + r#"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms) + VALUES ('test-workspace', 'u/operator/existing_flow', 'Existing flow', '', '{"modules": []}', 'admin', NOW(), '{}', '{}')"# + ) + .execute(&db) + .await?; + + let update_flow = json!({ + "path": "u/operator/existing_flow", + "summary": "Updated flow", + "description": "Updated", + "value": {"modules": []}, + "schema": {} + }); + + let update_flow_resp = operator_client + .post(&format!( + "{base_url}/w/test-workspace/flows/update/u/operator/existing_flow" + )) + .json(&update_flow) + .send() + .await + .unwrap(); + assert!( + !update_flow_resp.status().is_success(), + "Operator should NOT be able to update flows" + ); + + // ============================================ + // TEST: Operator cannot update apps + // ============================================ + + // Insert an app in operator's namespace + sqlx::query!( + r#"INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms) + VALUES (3001, 'test-workspace', 'u/operator/existing_app', 'Existing app', '{}', + '{"on_behalf_of": "u/admin", "on_behalf_of_email": "admin@windmill.dev", "execution_mode": "viewer"}', '{}')"# + ) + .execute(&db) + .await?; + + // Create an app version + sqlx::query!( + r#"INSERT INTO app_version (id, app_id, value, created_by, created_at) + VALUES (3001, 3001, '{"grid": []}', 'admin', NOW())"# + ) + .execute(&db) + .await?; + + // Update app versions + sqlx::query!( + "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001" + ) + .execute(&db) + .await?; + + let update_app = json!({ + "path": "u/operator/existing_app", + "summary": "Updated app", + "value": {"grid": []}, + "policy": { + "on_behalf_of": "u/operator", + "on_behalf_of_email": "operator@windmill.dev", + "execution_mode": "viewer" + } + }); + + let update_app_resp = operator_client + .post(&format!( + "{base_url}/w/test-workspace/apps/update/u/operator/existing_app" + )) + .json(&update_app) + .send() + .await + .unwrap(); + assert!( + !update_app_resp.status().is_success(), + "Operator should NOT be able to update apps" + ); + + Ok(()) +} diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index 3ef7e8a834..37698258bd 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -1905,6 +1905,11 @@ async fn archive_script_by_path( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> Result<()> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot archive scripts for security reasons".to_string(), + )); + } let path = path.to_path(); check_scopes(&authed, || format!("scripts:write:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -1974,6 +1979,11 @@ async fn archive_script_by_hash( Extension(webhook): Extension, Path((w_id, hash)): Path<(String, ScriptHash)>, ) -> JsonResult> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot archive scripts for security reasons".to_string(), + )); + } let mut tx = user_db.begin(&authed).await?; let script = sqlx::query_as::<_, Script>(