mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
* 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>
1087 lines
37 KiB
Rust
1087 lines
37 KiB
Rust
/*
|
|
* Author: Windmill Labs, Inc
|
|
* Copyright: Windmill Labs, Inc 2024
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
use itertools::Itertools;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashSet;
|
|
use windmill_common::error::{Error, Result};
|
|
|
|
/// Comprehensive scope system for JWT token authorization
|
|
///
|
|
/// Scopes follow the format: {domain}:{action}[:{resource}]
|
|
/// Examples:
|
|
/// - "jobs:read" - Read access to jobs
|
|
/// - "scripts:write:f/folder/*" - Write access to scripts in a folder
|
|
/// - "*" - Full access (superuser)
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ScopeDefinition {
|
|
pub domain: String,
|
|
pub action: String,
|
|
pub kind: Option<String>, // For jobs:run:kind (optional)
|
|
pub resource: Option<Vec<String>>,
|
|
}
|
|
|
|
impl ScopeDefinition {
|
|
pub fn new(
|
|
domain: &str,
|
|
action: &str,
|
|
kind: Option<&str>,
|
|
resource: Option<Vec<String>>,
|
|
) -> Self {
|
|
Self {
|
|
domain: domain.to_string(),
|
|
action: action.to_string(),
|
|
kind: kind.map(|s| s.to_string()),
|
|
resource: resource,
|
|
}
|
|
}
|
|
|
|
pub fn from_scope_string(scope: &str) -> Result<Self> {
|
|
let parts: Vec<&str> = scope.split(':').collect();
|
|
|
|
let into_owned_vec = |resources: &str| -> Vec<String> {
|
|
let resources = resources
|
|
.split(",")
|
|
.collect_vec()
|
|
.into_iter()
|
|
.map(ToOwned::to_owned)
|
|
.collect_vec();
|
|
|
|
resources
|
|
};
|
|
|
|
match parts.len() {
|
|
2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action
|
|
3 => {
|
|
if parts[0] == "jobs" && parts[1] == "run" {
|
|
Ok(Self::new(parts[0], parts[1], Some(parts[2]), None))
|
|
} else {
|
|
Ok(Self::new(
|
|
parts[0],
|
|
parts[1],
|
|
None,
|
|
Some(into_owned_vec(parts[2])),
|
|
))
|
|
}
|
|
}
|
|
4 => {
|
|
if parts[0] == "jobs" && parts[1] == "run" {
|
|
Ok(Self::new(
|
|
parts[0],
|
|
parts[1],
|
|
Some(parts[2]),
|
|
Some(into_owned_vec(parts[3])),
|
|
))
|
|
} else {
|
|
Err(Error::BadRequest(format!(
|
|
"Invalid 4-part scope: {}",
|
|
scope
|
|
)))
|
|
}
|
|
}
|
|
_ => Err(Error::BadRequest(format!(
|
|
"Invalid scope format: {}",
|
|
scope
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub fn as_string(&self) -> String {
|
|
match (&self.kind, &self.resource) {
|
|
(Some(kind), Some(resource)) => {
|
|
format!(
|
|
"{}:{}:{}:{}",
|
|
self.domain,
|
|
self.action,
|
|
kind,
|
|
resource.join(",")
|
|
)
|
|
}
|
|
(Some(kind), None) => {
|
|
format!("{}:{}:{}", self.domain, self.action, kind)
|
|
}
|
|
(None, Some(resource)) => {
|
|
format!("{}:{}:{}", self.domain, self.action, resource.join(","))
|
|
}
|
|
(None, None) => format!("{}:{}", self.domain, self.action),
|
|
}
|
|
}
|
|
|
|
pub fn includes(&self, other: &ScopeDefinition) -> bool {
|
|
if self.domain != other.domain {
|
|
return false;
|
|
}
|
|
|
|
match (self.action.as_str(), other.action.as_str()) {
|
|
(a, b) if (a == "write" && b == "read") || (a == b) => {}
|
|
_ => return false,
|
|
}
|
|
|
|
if self.domain == "jobs" && self.action == "run" {
|
|
match (&self.kind, &other.kind) {
|
|
(Some(self_kind), Some(other_kind)) => {
|
|
if self_kind != other_kind {
|
|
return false;
|
|
}
|
|
}
|
|
(Some(_), None) => {
|
|
return false;
|
|
}
|
|
(None, _) => {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
match (&self.resource, &other.resource) {
|
|
(Some(self_resources), Some(other_resources)) => {
|
|
resources_match(self_resources, other_resources)
|
|
}
|
|
(Some(_), None) => false,
|
|
(None, _) => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
|
if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) {
|
|
return true;
|
|
}
|
|
|
|
if scope_resources.len() <= 4 && accepted_resources.len() <= 4 {
|
|
return resources_match_small(scope_resources, accepted_resources);
|
|
}
|
|
|
|
resources_match_large(scope_resources, accepted_resources)
|
|
}
|
|
|
|
fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
|
for required in accepted_resources {
|
|
for scope_resource in scope_resources {
|
|
if resource_matches_pattern(scope_resource, required) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
|
let mut exact_matches = HashSet::new();
|
|
let mut patterns = Vec::new();
|
|
|
|
for scope_resource in scope_resources {
|
|
if scope_resource.contains('*') {
|
|
patterns.push(scope_resource);
|
|
} else {
|
|
exact_matches.insert(scope_resource);
|
|
}
|
|
}
|
|
|
|
for accepted_resource in accepted_resources {
|
|
if exact_matches.contains(accepted_resource) {
|
|
return true;
|
|
}
|
|
|
|
for pattern in &patterns {
|
|
if resource_matches_pattern(pattern, accepted_resource) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool {
|
|
if scope_resource == accepted_resource {
|
|
return true;
|
|
}
|
|
|
|
let matches_wildcard = |pattern: &str, resource: &str| -> bool {
|
|
if !pattern.ends_with("/*") {
|
|
return false;
|
|
}
|
|
|
|
let prefix = &pattern[..pattern.len() - 2];
|
|
|
|
if !resource.starts_with(prefix) {
|
|
return false;
|
|
}
|
|
|
|
// If the resource is exactly the prefix, it matches
|
|
if resource.len() == prefix.len() {
|
|
return true;
|
|
}
|
|
|
|
// If the resource is longer, the next character must be '/' for a valid match
|
|
// This prevents "u/user" from matching "u/use/*"
|
|
resource.chars().nth(prefix.len()) == Some('/')
|
|
};
|
|
|
|
// Check if either resource is a wildcard pattern and matches the other
|
|
matches_wildcard(scope_resource, accepted_resource)
|
|
|| matches_wildcard(accepted_resource, scope_resource)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Route-level scope checking
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
/// Available scope domains (top-level API categories)
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum ScopeDomain {
|
|
// Core resource domains
|
|
Jobs,
|
|
Scripts,
|
|
Flows,
|
|
FlowConversations,
|
|
Apps,
|
|
Variables,
|
|
Resources,
|
|
Schedules,
|
|
Folders,
|
|
Users,
|
|
Groups,
|
|
Workspaces,
|
|
|
|
// Trigger domains
|
|
HttpTriggers,
|
|
WebsocketTriggers,
|
|
KafkaTriggers,
|
|
NatsTriggers,
|
|
MqttTriggers,
|
|
SqsTriggers,
|
|
GcpTriggers,
|
|
AzureTriggers,
|
|
PostgresTriggers,
|
|
EmailTriggers,
|
|
|
|
// Native trigger domains
|
|
NativeTriggers,
|
|
|
|
// System domains
|
|
Audit,
|
|
Settings,
|
|
Workers,
|
|
ServiceLogs,
|
|
Configs,
|
|
OAuth,
|
|
AI,
|
|
|
|
Indexer,
|
|
Teams, // Microsoft Teams integration
|
|
GitSync, // Git synchronization
|
|
|
|
// Special domains
|
|
Capture, // Webhook capture
|
|
Drafts, // Draft resources
|
|
Favorites, // User favorites
|
|
Inputs, // Input templates
|
|
JobHelpers, // Job helper functions
|
|
ConcurrencyGroups, // Concurrency groups
|
|
Oidc, // OpenID Connect
|
|
Openapi, // OpenAPI generation
|
|
|
|
// Additional domains
|
|
Acls, // Granular access control lists
|
|
RawApps, // Raw application data
|
|
AgentWorkers, // Agent workers management
|
|
Mcp, // MCP
|
|
}
|
|
|
|
impl ScopeDomain {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Jobs => "jobs",
|
|
Self::Scripts => "scripts",
|
|
Self::Flows => "flows",
|
|
Self::FlowConversations => "flow_conversations",
|
|
Self::Apps => "apps",
|
|
Self::Variables => "variables",
|
|
Self::Resources => "resources",
|
|
Self::Schedules => "schedules",
|
|
Self::Folders => "folders",
|
|
Self::Users => "users",
|
|
Self::Groups => "groups",
|
|
Self::Workspaces => "workspaces",
|
|
Self::HttpTriggers => "http_triggers",
|
|
Self::WebsocketTriggers => "websocket_triggers",
|
|
Self::KafkaTriggers => "kafka_triggers",
|
|
Self::NatsTriggers => "nats_triggers",
|
|
Self::MqttTriggers => "mqtt_triggers",
|
|
Self::SqsTriggers => "sqs_triggers",
|
|
Self::GcpTriggers => "gcp_triggers",
|
|
Self::AzureTriggers => "azure_triggers",
|
|
Self::PostgresTriggers => "postgres_triggers",
|
|
Self::EmailTriggers => "email_triggers",
|
|
Self::NativeTriggers => "native_triggers",
|
|
Self::Audit => "audit",
|
|
Self::Settings => "settings",
|
|
Self::Workers => "workers",
|
|
Self::ServiceLogs => "service_logs",
|
|
Self::Configs => "configs",
|
|
Self::OAuth => "oauth",
|
|
Self::AI => "ai",
|
|
Self::Capture => "capture",
|
|
Self::Drafts => "drafts",
|
|
Self::Favorites => "favorites",
|
|
Self::Inputs => "inputs",
|
|
Self::JobHelpers => "job_helpers",
|
|
Self::ConcurrencyGroups => "concurrency_groups",
|
|
Self::Oidc => "oidc",
|
|
Self::Openapi => "openapi",
|
|
Self::Acls => "acls",
|
|
Self::RawApps => "raw_apps",
|
|
Self::AgentWorkers => "agent_workers",
|
|
Self::Indexer => "indexer",
|
|
Self::Teams => "teams",
|
|
Self::GitSync => "git_sync",
|
|
Self::Mcp => "mcp",
|
|
}
|
|
}
|
|
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"jobs" | "jobs_u" => Some(Self::Jobs),
|
|
"scripts" => Some(Self::Scripts),
|
|
"flows" => Some(Self::Flows),
|
|
"flow_conversations" => Some(Self::FlowConversations),
|
|
"apps" | "apps_u" => Some(Self::Apps),
|
|
"variables" => Some(Self::Variables),
|
|
"resources" => Some(Self::Resources),
|
|
"schedules" => Some(Self::Schedules),
|
|
"folders" => Some(Self::Folders),
|
|
"users" => Some(Self::Users),
|
|
"groups" => Some(Self::Groups),
|
|
"workspaces" => Some(Self::Workspaces),
|
|
"http_triggers" => Some(Self::HttpTriggers),
|
|
"websocket_triggers" => Some(Self::WebsocketTriggers),
|
|
"kafka_triggers" => Some(Self::KafkaTriggers),
|
|
"nats_triggers" => Some(Self::NatsTriggers),
|
|
"mqtt_triggers" => Some(Self::MqttTriggers),
|
|
"sqs_triggers" => Some(Self::SqsTriggers),
|
|
"gcp_triggers" => Some(Self::GcpTriggers),
|
|
"azure_triggers" => Some(Self::AzureTriggers),
|
|
"postgres_triggers" => Some(Self::PostgresTriggers),
|
|
"email_triggers" => Some(Self::EmailTriggers),
|
|
"audit" => Some(Self::Audit),
|
|
"settings" => Some(Self::Settings),
|
|
"workers" => Some(Self::Workers),
|
|
"service_logs" => Some(Self::ServiceLogs),
|
|
"configs" => Some(Self::Configs),
|
|
"oauth" => Some(Self::OAuth),
|
|
"ai" => Some(Self::AI),
|
|
"indexer" | "srch" => Some(Self::Indexer),
|
|
"teams" => Some(Self::Teams),
|
|
"native_triggers" => Some(Self::NativeTriggers),
|
|
"git_sync" | "github_app" => Some(Self::GitSync),
|
|
"capture" => Some(Self::Capture),
|
|
"drafts" => Some(Self::Drafts),
|
|
"favorites" => Some(Self::Favorites),
|
|
"inputs" => Some(Self::Inputs),
|
|
"job_helpers" => Some(Self::JobHelpers),
|
|
"concurrency_groups" => Some(Self::ConcurrencyGroups),
|
|
"oidc" => Some(Self::Oidc),
|
|
"openapi" => Some(Self::Openapi),
|
|
"acls" => Some(Self::Acls),
|
|
"raw_apps" => Some(Self::RawApps),
|
|
"agent_workers" => Some(Self::AgentWorkers),
|
|
"mcp" => Some(Self::Mcp),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Available scope actions
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum ScopeAction {
|
|
Read, // GET operations, list, view
|
|
Write, // POST, PUT, PATCH, DELETE operations, create, update, delete
|
|
Run, // Special action for running (scripts, flows, etc.)
|
|
}
|
|
|
|
impl ScopeAction {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Read => "read",
|
|
Self::Write => "write",
|
|
Self::Run => "run",
|
|
}
|
|
}
|
|
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"read" => Some(Self::Read),
|
|
"write" => Some(Self::Write),
|
|
"delete" => Some(Self::Write),
|
|
"run" => Some(Self::Run),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Check if this action includes another action
|
|
/// Write includes Read
|
|
pub fn includes(&self, other: &ScopeAction) -> bool {
|
|
match (self, other) {
|
|
(ScopeAction::Write, ScopeAction::Read) => true,
|
|
(ScopeAction::Run, ScopeAction::Read) => true,
|
|
(a, b) => a == b,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn check_route_access(
|
|
token_scopes: &[String],
|
|
route_path: &str,
|
|
http_method: &str,
|
|
) -> Result<()> {
|
|
// Map HTTP method to scope action (considering route context)
|
|
let required_action = map_http_method_to_action(http_method, route_path);
|
|
|
|
// Find the domain and kind for this route
|
|
let (required_domain, required_kind, route_suffix) = extract_domain_from_route(route_path)?;
|
|
|
|
// MCP scopes (mcp:all, mcp:favorites, mcp:hub:*, etc.) use a custom format
|
|
// that doesn't fit the standard domain:action model. Verify the token has at
|
|
// least one mcp: scope; MCP handlers do their own fine-grained checking.
|
|
if required_domain == ScopeDomain::Mcp {
|
|
let is_scoped_token = token_scopes
|
|
.iter()
|
|
.any(|s| !s.starts_with("if_jobs:filter_tags:"));
|
|
if !is_scoped_token {
|
|
return Ok(());
|
|
}
|
|
if token_scopes.iter().any(|s| s.starts_with("mcp:")) {
|
|
return Ok(());
|
|
}
|
|
return Err(Error::PermissionDenied(
|
|
"Access denied. Required scope: mcp:*".to_string(),
|
|
));
|
|
}
|
|
|
|
// tracing::error!("Checking route access {:?} {:?} {:?} {:?}", required_action, required_domain, required_kind, route_suffix);
|
|
let mut is_scoped_token = false;
|
|
// Check if any token scope grants the required access
|
|
for scope_str in token_scopes {
|
|
if !scope_str.starts_with("if_jobs:filter_tags:") {
|
|
if let Ok(scope) = ScopeDefinition::from_scope_string(scope_str) {
|
|
// tracing::error!("Checking scope {:?} for required domain {:?} and action {:?} and kind {:?} and route suffix {:?}", scope, required_domain, required_action, required_kind, route_suffix);
|
|
if scope_grants_access(
|
|
&scope,
|
|
required_domain,
|
|
required_action,
|
|
required_kind.as_deref(),
|
|
route_suffix.as_deref(),
|
|
)? {
|
|
// tracing::error!("Scope grants access: {:?}", scope);
|
|
return Ok(());
|
|
}
|
|
}
|
|
if !is_scoped_token {
|
|
is_scoped_token = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
//Edge case for backward compatibility, if only scopes defined was filter tag then don't treat this we don't treat the token
|
|
//as a restricted token
|
|
if !is_scoped_token {
|
|
return Ok(());
|
|
}
|
|
let scope_display = if let Some(kind) = required_kind {
|
|
format!(
|
|
"{}:{}:{}",
|
|
required_domain.as_str(),
|
|
required_action.as_str(),
|
|
kind
|
|
)
|
|
} else {
|
|
format!("{}:{}", required_domain.as_str(), required_action.as_str())
|
|
};
|
|
|
|
Err(Error::PermissionDenied(format!(
|
|
"Access denied. Required scope: {}",
|
|
scope_display
|
|
)))
|
|
}
|
|
|
|
const SCRIPT_JOBS: [&'static str; 8] = [
|
|
"jobs/run/p",
|
|
"jobs/run/h",
|
|
"jobs/run_wait_result/p",
|
|
"jobs/run_wait_result/h",
|
|
"jobs/run/preview_bundle",
|
|
"jobs/run/preview",
|
|
"jobs/run_and_stream/p",
|
|
"jobs/run_and_stream/h",
|
|
];
|
|
|
|
const FLOW_JOBS: [&'static str; 6] = [
|
|
"jobs/run/f",
|
|
"jobs/run_wait_result/f",
|
|
"jobs/run/preview_flow",
|
|
"jobs/restart/f",
|
|
"jobs/flow/resume",
|
|
"jobs/run_and_stream/f",
|
|
];
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref RUN_PATH_ACTIONS: Vec<&'static str> = {
|
|
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component"];
|
|
|
|
v.extend(SCRIPT_JOBS);
|
|
v.extend(FLOW_JOBS);
|
|
v
|
|
};
|
|
}
|
|
|
|
fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction {
|
|
if RUN_PATH_ACTIONS
|
|
.iter()
|
|
.any(|run_path| route_path.contains(run_path))
|
|
{
|
|
return ScopeAction::Run;
|
|
}
|
|
|
|
match method.to_uppercase().as_str() {
|
|
"GET" | "HEAD" | "OPTIONS" => ScopeAction::Read,
|
|
"POST" | "PUT" | "PATCH" | "DELETE" => ScopeAction::Write,
|
|
_ => ScopeAction::Read,
|
|
}
|
|
}
|
|
|
|
/// Checks the route path to determine the runnable kind (either "flows" or "scripts").
|
|
///
|
|
/// The order of checks is important:
|
|
/// - Flow-related paths are checked first to avoid false positives, as some flow paths
|
|
/// (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`).
|
|
///
|
|
/// Returns `"flows"` or `"scripts"` based on the match, or `None` if no match is found.
|
|
fn determine_kind_from_route(route_path: &str) -> Option<String> {
|
|
if route_path.starts_with("jobs") {
|
|
if FLOW_JOBS.iter().any(|path| route_path.starts_with(path)) {
|
|
return Some("flows".to_string());
|
|
} else if SCRIPT_JOBS.iter().any(|path| route_path.starts_with(path)) {
|
|
return Some("scripts".to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn extract_domain_from_route(
|
|
route_path: &str,
|
|
) -> Result<(ScopeDomain, Option<String>, Option<String>)> {
|
|
// Examples:
|
|
// - /api/w/workspace/jobs/123 -> jobs domain (workspaced)
|
|
// - /api/teams/sync -> teams domain (global)
|
|
// - /api/srch/index/search -> indexer domain (global)
|
|
let parts: Vec<&str> = route_path.split('/').collect();
|
|
|
|
let (domain, kind, route_suffix) = if parts.len() >= 5 && parts[1] == "api" && parts[2] == "w" {
|
|
let domain_part = parts[4];
|
|
let route_suffix = &parts[4..].join("/");
|
|
|
|
let domain = ScopeDomain::from_str(domain_part);
|
|
|
|
let kind = determine_kind_from_route(&route_suffix);
|
|
|
|
(domain, kind, Some(route_suffix.to_owned()))
|
|
} else if parts.len() >= 3 && parts[1] == "api" {
|
|
(
|
|
ScopeDomain::from_str(parts[2]),
|
|
None,
|
|
Some(parts[2..].join("/")),
|
|
)
|
|
} else {
|
|
(None, None, None)
|
|
};
|
|
|
|
if let Some(domain) = domain {
|
|
// tracing::error!("Extracted domain {:?} from route {:?} with kind {:?} and route suffix {:?}", domain, route_path, kind, route_suffix);
|
|
return Ok((domain, kind, route_suffix));
|
|
}
|
|
|
|
Err(Error::BadRequest(format!(
|
|
"Could not extract domain from route: {}",
|
|
route_path
|
|
)))
|
|
}
|
|
|
|
const RUN_WHITELISTED_GET_PATHS: [&'static str; 20] = [
|
|
"jobs_u/get_flow/",
|
|
"jobs_u/get_root_job_id/",
|
|
"jobs_u/get/",
|
|
"jobs_u/get_logs/",
|
|
"jobs_u/get_flow_all_logs/",
|
|
"jobs_u/get_args/",
|
|
"jobs_u/get_flow_debug_info/",
|
|
"jobs_u/completed/get/",
|
|
"jobs_u/completed/get_result/",
|
|
"jobs_u/completed/get_result_maybe/",
|
|
"jobs_u/getupdate/",
|
|
"jobs_u/getupdate_sse/",
|
|
"jobs_u/get_log_file/",
|
|
"jobs/result_by_id/",
|
|
"jobs/resume_urls/",
|
|
"jobs/flow/user_states/",
|
|
"jobs/job_signature/",
|
|
"jobs/completed/get/",
|
|
"jobs/completed/get_result/",
|
|
"jobs/completed/get_result_maybe/",
|
|
];
|
|
|
|
fn scope_grants_access(
|
|
scope: &ScopeDefinition,
|
|
required_domain: ScopeDomain,
|
|
required_action: ScopeAction,
|
|
required_kind: Option<&str>,
|
|
route_path: Option<&str>,
|
|
) -> Result<bool> {
|
|
// Check domain match
|
|
let scope_domain = ScopeDomain::from_str(&scope.domain)
|
|
.ok_or_else(|| Error::BadRequest(format!("Invalid scope domain: {}", scope.domain)))?;
|
|
|
|
if scope_domain != required_domain {
|
|
return Ok(false);
|
|
}
|
|
|
|
// Check action match (with hierarchical permissions)
|
|
let scope_action = ScopeAction::from_str(&scope.action)
|
|
.ok_or_else(|| Error::BadRequest(format!("Invalid scope action: {}", scope.action)))?;
|
|
|
|
if !scope_action.includes(&required_action)
|
|
&& !(scope_domain == ScopeDomain::Jobs
|
|
&& required_action == ScopeAction::Read
|
|
&& route_path.is_some_and(|p| {
|
|
RUN_WHITELISTED_GET_PATHS
|
|
.iter()
|
|
.any(|path| p.starts_with(path))
|
|
}))
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
if scope_domain == ScopeDomain::Jobs && required_action == ScopeAction::Run {
|
|
match (&scope.kind, required_kind) {
|
|
(Some(scope_kind), Some(req_kind)) => {
|
|
if scope_kind != req_kind {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
(None, _) => {}
|
|
(Some(_), None) => {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// No resource specified means access to entire domain
|
|
Ok(true)
|
|
}
|
|
|
|
/// Enforces a token's `read_only` flag: only methods classified as `Read`
|
|
/// (GET/HEAD/OPTIONS) are allowed. Run actions and mutating methods are
|
|
/// rejected. Independent of `scopes`.
|
|
pub fn check_read_only_for_route(route_path: &str, http_method: &str) -> Result<()> {
|
|
if map_http_method_to_action(http_method, route_path) == ScopeAction::Read {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::PermissionDenied(
|
|
"Token is read-only. Mutating endpoints are not allowed.".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Helper function to check if scopes allow access to a route
|
|
pub fn check_scopes_for_route(
|
|
token_scopes: Option<&[String]>,
|
|
route_path: &str,
|
|
http_method: &str,
|
|
) -> Result<()> {
|
|
// If no scopes defined, allow access (backward compatibility)
|
|
let scopes = match token_scopes {
|
|
Some(s) if !s.is_empty() => s,
|
|
_ => return Ok(()),
|
|
};
|
|
|
|
check_route_access(scopes, route_path, http_method)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_scope_definition_parsing() {
|
|
let scope = ScopeDefinition::from_scope_string("jobs:read").unwrap();
|
|
assert_eq!(scope.domain, "jobs");
|
|
assert_eq!(scope.action, "read");
|
|
assert_eq!(scope.kind, None);
|
|
assert_eq!(scope.resource, None);
|
|
|
|
let scope = ScopeDefinition::from_scope_string("jobs:run:scripts:f/folder/*").unwrap();
|
|
assert_eq!(scope.domain, "jobs");
|
|
assert_eq!(scope.action, "run");
|
|
assert_eq!(scope.kind, Some("scripts".to_string()));
|
|
assert_eq!(scope.resource, Some(vec!["f/folder/*".to_string()]));
|
|
|
|
// Test jobs:run:kind parsing
|
|
let scope = ScopeDefinition::from_scope_string("jobs:run:scripts").unwrap();
|
|
assert_eq!(scope.domain, "jobs");
|
|
assert_eq!(scope.action, "run");
|
|
assert_eq!(scope.kind, Some("scripts".to_string()));
|
|
assert_eq!(scope.resource, None);
|
|
|
|
// Test jobs:run:kind:resource parsing
|
|
let scope = ScopeDefinition::from_scope_string("jobs:run:flows:f/folder/*").unwrap();
|
|
assert_eq!(scope.domain, "jobs");
|
|
assert_eq!(scope.action, "run");
|
|
assert_eq!(scope.kind, Some("flows".to_string()));
|
|
assert_eq!(scope.resource, Some(vec!["f/folder/*".to_string()]));
|
|
|
|
// Test comma-separated resources parsing
|
|
let scope =
|
|
ScopeDefinition::from_scope_string("scripts:read:path1,path2,f/folder/*").unwrap();
|
|
assert_eq!(scope.domain, "scripts");
|
|
assert_eq!(scope.action, "read");
|
|
assert_eq!(scope.kind, None);
|
|
assert_eq!(
|
|
scope.resource,
|
|
Some(vec![
|
|
"path1".to_string(),
|
|
"path2".to_string(),
|
|
"f/folder/*".to_string()
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scope_action_hierarchy() {
|
|
assert!(ScopeAction::Write.includes(&ScopeAction::Read));
|
|
assert!(!ScopeAction::Read.includes(&ScopeAction::Write));
|
|
assert!(ScopeAction::Run.includes(&ScopeAction::Read));
|
|
assert!(!ScopeAction::Run.includes(&ScopeAction::Write));
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_domain_extraction() {
|
|
let (domain, kind, route_suffix) =
|
|
extract_domain_from_route("/api/w/test_workspace/jobs/123").unwrap();
|
|
assert_eq!(domain, ScopeDomain::Jobs);
|
|
assert_eq!(kind, None);
|
|
assert_eq!(route_suffix, Some("jobs/123".to_string()));
|
|
|
|
let (domain, kind, route_suffix) =
|
|
extract_domain_from_route("/api/w/test_workspace/scripts/test_script").unwrap();
|
|
assert_eq!(domain, ScopeDomain::Scripts);
|
|
assert_eq!(kind, None);
|
|
assert_eq!(route_suffix, Some("scripts/test_script".to_string()));
|
|
|
|
let (domain, kind, route_suffix) =
|
|
extract_domain_from_route("/api/w/test_workspace/flow_conversations/list").unwrap();
|
|
assert_eq!(domain, ScopeDomain::FlowConversations);
|
|
assert_eq!(kind, None);
|
|
assert_eq!(route_suffix, Some("flow_conversations/list".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_read_only_for_route() {
|
|
// Plain GETs pass.
|
|
assert!(check_read_only_for_route("/api/w/x/scripts/list", "GET").is_ok());
|
|
assert!(check_read_only_for_route("/api/w/x/scripts/get/foo", "HEAD").is_ok());
|
|
assert!(check_read_only_for_route("/api/w/x/anything", "OPTIONS").is_ok());
|
|
|
|
// Mutating methods are rejected.
|
|
assert!(check_read_only_for_route("/api/w/x/scripts/create", "POST").is_err());
|
|
assert!(check_read_only_for_route("/api/w/x/scripts/update", "PUT").is_err());
|
|
assert!(check_read_only_for_route("/api/w/x/scripts/delete", "DELETE").is_err());
|
|
assert!(check_read_only_for_route("/api/w/x/scripts/patch", "PATCH").is_err());
|
|
|
|
// Run paths are rejected even on GET (map_http_method_to_action elevates
|
|
// them to Run via RUN_PATH_ACTIONS).
|
|
assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "GET").is_err());
|
|
assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "POST").is_err());
|
|
|
|
// OAuth/registration endpoints under /api/mcp/* must NOT be exempted by
|
|
// the auth middleware — they go through this check on the gateway side
|
|
// because they can mint non-read-only tokens. The middleware decides
|
|
// which paths to exempt; this helper is method-only, so we just assert
|
|
// that mutating methods still fail.
|
|
assert!(
|
|
check_read_only_for_route("/api/mcp/gateway/oauth/server/approve", "POST").is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_specific_scope_access() {
|
|
let scopes = vec!["jobs:read".to_string()];
|
|
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/jobs/123", "GET").is_ok());
|
|
|
|
// DELETE now requires write permission, so it should still fail with read-only scope
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/jobs/123", "DELETE").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_domain_parsing() {
|
|
// Test that new domains are properly parsed
|
|
assert_eq!(ScopeDomain::from_str("acls"), Some(ScopeDomain::Acls));
|
|
assert_eq!(
|
|
ScopeDomain::from_str("raw_apps"),
|
|
Some(ScopeDomain::RawApps)
|
|
);
|
|
assert_eq!(
|
|
ScopeDomain::from_str("agent_workers"),
|
|
Some(ScopeDomain::AgentWorkers)
|
|
);
|
|
assert_eq!(
|
|
ScopeDomain::from_str("flow_conversations"),
|
|
Some(ScopeDomain::FlowConversations)
|
|
);
|
|
|
|
// Test canonical string conversion
|
|
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
|
|
assert_eq!(ScopeDomain::RawApps.as_str(), "raw_apps");
|
|
assert_eq!(ScopeDomain::AgentWorkers.as_str(), "agent_workers");
|
|
assert_eq!(
|
|
ScopeDomain::FlowConversations.as_str(),
|
|
"flow_conversations"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_flow_conversations_scope_access() {
|
|
let read_scopes = vec!["flow_conversations:read".to_string()];
|
|
assert!(check_route_access(
|
|
&read_scopes,
|
|
"/api/w/test_workspace/flow_conversations/list",
|
|
"GET"
|
|
)
|
|
.is_ok());
|
|
assert!(check_route_access(
|
|
&read_scopes,
|
|
"/api/w/test_workspace/flow_conversations/123/messages",
|
|
"GET"
|
|
)
|
|
.is_ok());
|
|
assert!(check_route_access(
|
|
&read_scopes,
|
|
"/api/w/test_workspace/flow_conversations/delete/123",
|
|
"DELETE"
|
|
)
|
|
.is_err());
|
|
|
|
let write_scopes = vec!["flow_conversations:write".to_string()];
|
|
assert!(check_route_access(
|
|
&write_scopes,
|
|
"/api/w/test_workspace/flow_conversations/delete/123",
|
|
"DELETE"
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resource_array_matching() {
|
|
// Test wildcard access
|
|
let scope_all = ScopeDefinition::new("scripts", "read", None, Some(vec!["*".to_string()]));
|
|
let required = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["path1".to_string(), "path2".to_string()]),
|
|
);
|
|
assert!(scope_all.includes(&required));
|
|
|
|
// Test exact matches
|
|
let scope_exact = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["path1".to_string(), "path2".to_string()]),
|
|
);
|
|
let required_subset =
|
|
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
|
|
assert!(scope_exact.includes(&required_subset));
|
|
|
|
// Test partial match - should grant access if ANY required resource matches
|
|
let scope_limited =
|
|
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
|
|
let required_partial = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["path1".to_string(), "path2".to_string()]),
|
|
);
|
|
assert!(scope_limited.includes(&required_partial)); // path1 matches, so access granted
|
|
|
|
// Test no match - scope doesn't cover any of the required resources
|
|
let scope_different =
|
|
ScopeDefinition::new("scripts", "read", None, Some(vec!["path3".to_string()]));
|
|
let required_no_match = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["path1".to_string(), "path2".to_string()]),
|
|
);
|
|
assert!(!scope_different.includes(&required_no_match));
|
|
|
|
// Test pattern matching
|
|
let scope_pattern = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["f/folder/*".to_string()]),
|
|
);
|
|
let required_in_folder = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["f/folder/script1".to_string()]),
|
|
);
|
|
assert!(scope_pattern.includes(&required_in_folder));
|
|
|
|
// Test mixed patterns and exact matches
|
|
let scope_mixed = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["exact_path".to_string(), "f/folder/*".to_string()]),
|
|
);
|
|
let required_mixed1 = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["exact_path".to_string()]),
|
|
);
|
|
let required_mixed2 = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["f/folder/script2".to_string()]),
|
|
);
|
|
assert!(scope_mixed.includes(&required_mixed1));
|
|
assert!(scope_mixed.includes(&required_mixed2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_efficiency_small_vs_large_arrays() {
|
|
// Test small array optimization path
|
|
let scope_small = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["path1".to_string(), "path2".to_string()]),
|
|
);
|
|
let required_small =
|
|
ScopeDefinition::new("scripts", "read", None, Some(vec!["path1".to_string()]));
|
|
assert!(scope_small.includes(&required_small));
|
|
|
|
// Test large array optimization path
|
|
let large_scope_vec: Vec<String> = (0..10).map(|i| format!("path{}", i)).collect();
|
|
let scope_large = ScopeDefinition::new("scripts", "read", None, Some(large_scope_vec));
|
|
let required_large =
|
|
ScopeDefinition::new("scripts", "read", None, Some(vec!["path5".to_string()]));
|
|
assert!(scope_large.includes(&required_large));
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_example_case() {
|
|
let user_scope =
|
|
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/dieri/*".to_string()]));
|
|
let required_mixed = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["u/dadad/wqdq".to_string(), "u/*".to_string()]),
|
|
);
|
|
assert!(user_scope.includes(&required_mixed));
|
|
|
|
let scope_specific = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["folder/file1".to_string()]),
|
|
);
|
|
let required_multi = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["folder/file1".to_string(), "other/file2".to_string()]),
|
|
);
|
|
assert!(scope_specific.includes(&required_multi));
|
|
|
|
let scope_broad =
|
|
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/*".to_string()]));
|
|
let required_specific = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["u/dieri/script.py".to_string()]),
|
|
);
|
|
assert!(scope_broad.includes(&required_specific));
|
|
|
|
let scope_specific_path = ScopeDefinition::new(
|
|
"scripts",
|
|
"read",
|
|
None,
|
|
Some(vec!["u/dieri/script.py".to_string()]),
|
|
);
|
|
let required_broad =
|
|
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/*".to_string()]));
|
|
assert!(scope_specific_path.includes(&required_broad));
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_scope_bypass_blocked_without_mcp_scope() {
|
|
// A token with only jobs:read should NOT be able to access MCP endpoints
|
|
let scopes = vec!["jobs:read".to_string()];
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_err());
|
|
assert!(
|
|
check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "POST").is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_scope_allowed_with_mcp_scope() {
|
|
// A token with mcp:all should access MCP endpoints
|
|
let scopes = vec!["mcp:all".to_string()];
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
|
|
|
// mcp:favorites should also work
|
|
let scopes = vec!["mcp:favorites".to_string()];
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "POST").is_ok());
|
|
|
|
// mcp:scripts:path should also work
|
|
let scopes = vec!["mcp:scripts:u/admin/script1".to_string()];
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_scope_filter_tags_only_treated_as_unrestricted() {
|
|
// Token with only filter_tags is not considered scoped — should be allowed
|
|
let scopes = vec!["if_jobs:filter_tags:tag1".to_string()];
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_scope_mixed_scopes_without_mcp() {
|
|
// Token with multiple non-MCP scopes should be denied
|
|
let scopes = vec!["jobs:read".to_string(), "scripts:write".to_string()];
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_scope_mixed_scopes_with_mcp() {
|
|
// Token with MCP scope + other scopes should be allowed for MCP
|
|
let scopes = vec!["jobs:read".to_string(), "mcp:all".to_string()];
|
|
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
|
}
|
|
}
|