mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
* feat: guest app execution mode, a fourth role that takes no seat Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: make the guest grant a server-minted label, not a declarable scope Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * chore: pin ee-repo-ref to the guest session companion branch Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: close the relabel hole, guest embed tokens, read-path switch, custom-path entry Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest tokens are not rescopable and guest embed tokens keep the sentinel Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest-derived tokens share one constraint set; gate sign-in on guest discovery Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: the label alone governs a guest; refuse guests with accounts; unserialize discovery Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest discovery fails closed; SAML aborts if the guest cookie write fails Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * refactor: enforce the guest switch once at the auth door; sign-in for a guest of another app Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest app-mode decided once at the on-behalf resolver; clear a stale guest session before offering another app's sign-in Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: a guest may use anonymous apps; await the stale-session logout; trim comments Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: a guest's path confinement waits for the app's mode, so anonymous apps stay open to it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest target survives http (Lax cookie), rides SAML RelayState; tell account holders on arrival * fix: a guest uses an anonymous app as itself; S3 uploads confined by app mode * fix: a guest upload needs an app policy; a missing app does not skip the confinement * fix: guests are gated on the Enterprise plan server-side; pin ee-repo-ref * fix: the guest plan gate fails closed on non-enterprise builds; settings report the effective switch * fix: guest controls read the plan, not the key; gate the guest tests on the features they need * docs: tighten the guest session invariant comments * feat: 100 free guests per 30 days, then a quarter seat each on Enterprise and a hard cap elsewhere; superadmin guest list; refusals reach the page Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: the cap is exact, an account ends a guest session at the door, popups close, and guest mode survives the CLI round trip Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * feat: a superadmin switch over guests for the whole instance; the pre-existing-user flag keeps its meaning Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: drop the dead guest-access helper, name the instance setting once, guests tab states, CE save order Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: a guest app path is refused at the mint if it could widen the scope; the instance toggle waits for its reload Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guests stop at the launched-by-me job grant; canonical app paths at the mint and discovery; the toggle ends on the stored value Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: only the scope grammar's own characters bar an app path from guests, refused at deploy as well as at the mint Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: the deploy-time guest path guard checks the destination of a rename and refuses a leading slash Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: a workspace rename keeps the guest switch; the rename guard reads the deployed mode under the row lock Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest_activity follows a workspace rename and goes with a workspace delete Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * chore: pin ee-repo-ref to the state-bound guest target Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * chore: pin ee-repo-ref; the guest cookie is never cleared by a callback Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * docs: the workspace-scoped guest_activity delete moves an instance-wide count; assert the mint records the guest Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * test: the seeded allowance is a day old, so only the mint can write today's guest_activity row Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * chore: update ee-repo-ref to 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07 This commit updates the EE repository reference after PR #769 was merged in windmill-ee-private. Previous ee-repo-ref: 32841072aa396bff91d30bd91854fa348cb3c439 New ee-repo-ref: 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1741 lines
68 KiB
Rust
1741 lines
68 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) => {}
|
|
// Apps only: `write` can rewrite the app and its policy, so it also covers
|
|
// running its components. Not general — `jobs:write` must not grant
|
|
// `jobs:run`. The resource check below still confines it to the same app.
|
|
("write", "run") if self.domain == "apps" => {}
|
|
_ => 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)
|
|
}
|
|
// A requirement naming no path is the whole domain, so only a grant that
|
|
// itself spans every path satisfies it. `*` is that grant — the scope UI
|
|
// accepts it as a resource path and `resources_match` already reads it as
|
|
// everything — while any listed path leaves the collection unauthorized.
|
|
(Some(self_resources), None) => self_resources.iter().any(|r| r == "*"),
|
|
(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,
|
|
/// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a
|
|
/// `data_metrics:read` token must reach only this route, never the broader
|
|
/// `/scripts` routes (some of which do no further scope check).
|
|
DataMetrics,
|
|
Flows,
|
|
FlowConversations,
|
|
Apps,
|
|
Variables,
|
|
Resources,
|
|
Schedules,
|
|
Folders,
|
|
Users,
|
|
Groups,
|
|
Workspaces,
|
|
|
|
// Trigger domains
|
|
HttpTriggers,
|
|
WebsocketTriggers,
|
|
KafkaTriggers,
|
|
NatsTriggers,
|
|
MqttTriggers,
|
|
AmqpTriggers,
|
|
SqsTriggers,
|
|
GcpTriggers,
|
|
AzureTriggers,
|
|
PostgresTriggers,
|
|
EmailTriggers,
|
|
|
|
// Native trigger domains
|
|
NativeTriggers,
|
|
TriggersHistory,
|
|
|
|
// System domains
|
|
Audit,
|
|
Settings,
|
|
Workers,
|
|
ServiceLogs,
|
|
Configs,
|
|
OAuth,
|
|
AI,
|
|
AiEvals, // AI agent eval datasets
|
|
|
|
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
|
|
Docs, // Self-hosted documentation search (read-only)
|
|
}
|
|
|
|
impl ScopeDomain {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Jobs => "jobs",
|
|
Self::Scripts => "scripts",
|
|
Self::DataMetrics => "data_metrics",
|
|
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::AmqpTriggers => "amqp_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::TriggersHistory => "triggers_history",
|
|
Self::Audit => "audit",
|
|
Self::Settings => "settings",
|
|
Self::Workers => "workers",
|
|
Self::ServiceLogs => "service_logs",
|
|
Self::Configs => "configs",
|
|
Self::OAuth => "oauth",
|
|
Self::AI => "ai",
|
|
Self::AiEvals => "ai_evals",
|
|
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",
|
|
Self::Docs => "docs",
|
|
}
|
|
}
|
|
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"jobs" | "jobs_u" => Some(Self::Jobs),
|
|
"scripts" => Some(Self::Scripts),
|
|
// A distinct domain, not an alias of `scripts` (see the enum variant):
|
|
// a `data_metrics:read` token must not reach the broader /scripts routes.
|
|
"data_metrics" => Some(Self::DataMetrics),
|
|
"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),
|
|
"amqp_triggers" => Some(Self::AmqpTriggers),
|
|
"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),
|
|
"ai_evals" => Some(Self::AiEvals),
|
|
"indexer" | "srch" => Some(Self::Indexer),
|
|
"teams" => Some(Self::Teams),
|
|
"native_triggers" => Some(Self::NativeTriggers),
|
|
"triggers_history" => Some(Self::TriggersHistory),
|
|
"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),
|
|
"docs" => Some(Self::Docs),
|
|
_ => 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)?;
|
|
|
|
// App embed tokens (sentinel) carry broad read scopes (`jobs:read`,
|
|
// `users:read`, `folders:read`) that exist only for a handful of routes. The
|
|
// whole `/users`, `/folders` and `/jobs` routers are CORS-enabled for the
|
|
// opaque app iframe, so default-deny everything in those domains except the
|
|
// intended routes — otherwise the token could enumerate/export workspace data.
|
|
if has_app_embed_sentinel(Some(token_scopes)) {
|
|
if let Some(suffix) = route_suffix.as_deref() {
|
|
if app_embed_route_denied(required_domain, suffix) {
|
|
return Err(Error::PermissionDenied(
|
|
"Access denied. App embed token cannot access this route.".to_string(),
|
|
));
|
|
}
|
|
// The by-id job cancel is a POST (write) that the token's `jobs:read`
|
|
// wouldn't satisfy, but cancelling the app's own component runs is
|
|
// intended (most components supersede an in-flight run on re-run). Permit
|
|
// it here; `cancel_job_api` confines it to jobs the app launched
|
|
// (created_by == viewer). A read_only token is still rejected by the
|
|
// separate read-only check.
|
|
if suffix.starts_with("jobs_u/queue/cancel/") {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
// A guest session carries the same broad read scopes as an embed token and for
|
|
// the same handful of routes, so it gets the same default-deny.
|
|
if has_guest_sentinel(Some(token_scopes)) {
|
|
if let Some(suffix) = route_suffix.as_deref() {
|
|
if guest_route_denied(required_domain, suffix) {
|
|
return Err(Error::PermissionDenied(format!(
|
|
"a guest session cannot access {route_path}"
|
|
)));
|
|
}
|
|
// Same rationale as the embed branch: re-running a component supersedes
|
|
// its in-flight run, and `cancel_job_api` confines this to the caller's
|
|
// own jobs.
|
|
if suffix.starts_with("jobs_u/queue/cancel/") {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Each declared scope must grant what its prompt said and no more:
|
|
// `jobs:run` only deployed runnables, `users:read` only the viewer's identity.
|
|
if has_raw_app_sdk_sentinel(Some(token_scopes)) {
|
|
if let Some(suffix) = route_suffix.as_deref() {
|
|
if is_request_supplied_code_route(suffix) {
|
|
return Err(Error::PermissionDenied(
|
|
"Access denied. A raw app frontend SDK token cannot run request-supplied code."
|
|
.to_string(),
|
|
));
|
|
}
|
|
if required_domain == ScopeDomain::Users && suffix != "users/whoami" {
|
|
return Err(Error::PermissionDenied(
|
|
"Access denied. A raw app frontend SDK token can only read the viewer's own identity."
|
|
.to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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", "apps_u/upload_s3_file"];
|
|
|
|
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") {
|
|
// Preview/bundle runs execute arbitrary code with no deployed path, so
|
|
// their handlers require the broad `jobs:run` scope: they must carry no
|
|
// kind, else the derived scope is narrower than the handler demands.
|
|
// Anchor to the endpoint segment so by-path runs of a deployed runnable
|
|
// whose path contains "preview" (e.g. `run/p/f/team/preview_report`) are
|
|
// still classified by their kind.
|
|
if route_path.starts_with("jobs/run/preview")
|
|
|| route_path.starts_with("jobs/run_wait_result/preview")
|
|
{
|
|
return None;
|
|
}
|
|
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
|
|
)))
|
|
}
|
|
|
|
/// The reads a `jobs:run` scope implies: following, by id, a run the token started.
|
|
/// Every entry is keyed by a job id and confines an authenticated caller to its own
|
|
/// runnable — through `require_job_read_access`, through its own `jobs:run:flows:<path>`
|
|
/// check, or, where an approval token or resume secret bypasses that gate, through a
|
|
/// direct `require_job_within_run_scope`. The one exception is
|
|
/// `jobs_u/get_root_job_id/`, which has no check at all but discloses only flow lineage,
|
|
/// to anyone, authenticated or not. Workspace-wide enumeration (`jobs/list`, counts,
|
|
/// exports) and credential minting (`job_view_token`) are deliberately absent — those are
|
|
/// `jobs:read`. Keep by-id read routes here in sync as they are added, or a run token
|
|
/// loses the ability to follow its own run through them.
|
|
const RUN_WHITELISTED_GET_PATHS: [&'static str; 32] = [
|
|
"jobs_u/get_flow/",
|
|
"jobs_u/get_root_job_id/",
|
|
"jobs_u/get/",
|
|
"jobs_u/get_logs/",
|
|
"jobs_u/get_completed_logs_tail/",
|
|
"jobs_u/get_flow_all_logs/",
|
|
"jobs_u/get_flow_all_logs_structured/",
|
|
"jobs_u/get_flow_all_results/",
|
|
"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/completed/get_timing/",
|
|
"jobs_u/dispatch_events/",
|
|
"jobs_u/getupdate/",
|
|
"jobs_u/getupdate_sse/",
|
|
"jobs_u/get_log_file/",
|
|
"jobs/run_progress/",
|
|
"jobs/dbt_graph/",
|
|
"jobs/dbt_resumable/",
|
|
"jobs/dbt_resumable_script/p/",
|
|
"jobs/result_by_id/",
|
|
"jobs/resume_urls/",
|
|
"jobs/flow/user_states/",
|
|
"jobs/job_signature/",
|
|
"jobs/wac_approval_urls/",
|
|
"jobs/completed/get/",
|
|
"jobs/completed/get_result/",
|
|
"jobs/completed/get_result_maybe/",
|
|
"jobs/completed/get_timing/",
|
|
"jobs/get_otel_traces/",
|
|
];
|
|
|
|
/// Sentinel scope in app embed tokens. Grants nothing itself; `check_route_access`
|
|
/// uses it to deny the workspace-wide job enumeration routes `jobs:read` would
|
|
/// otherwise reach, so an embedded app reads only jobs it launched (by id).
|
|
pub const APP_EMBED_SENTINEL: &str = "app_embed";
|
|
|
|
/// True if a token's scopes include the app-embed sentinel (a sandboxed app iframe
|
|
/// token). Such tokens carry the viewer's identity but represent untrusted app JS,
|
|
/// so several handlers confine them to the app's own resources/runs.
|
|
pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
|
|
scopes.is_some_and(|s| s.iter().any(|x| x == APP_EMBED_SENTINEL))
|
|
}
|
|
|
|
/// Sentinel in a guest session token: someone the identity provider authenticated
|
|
/// who is a member of no workspace. Grants nothing itself — it only confines the
|
|
/// session to the app surface, the same way `app_embed` does. What makes a session a
|
|
/// guest at all is the server-minted label
|
|
/// [`windmill_common::auth::GUEST_SESSION_LABEL`]; a forged sentinel here can only
|
|
/// narrow its own token.
|
|
pub const GUEST_SENTINEL: &str = "guest";
|
|
|
|
/// True if a token is a guest session, whose scopes are its entire grant: it has no ACL
|
|
/// of its own, so every ACL check denies it unaided.
|
|
pub fn has_guest_sentinel(scopes: Option<&[String]>) -> bool {
|
|
scopes.is_some_and(|s| s.iter().any(|x| x == GUEST_SENTINEL))
|
|
}
|
|
|
|
/// `scopes` with the guest sentinel present exactly once.
|
|
pub fn with_guest_sentinel(mut scopes: Vec<String>) -> Vec<String> {
|
|
if !scopes.iter().any(|x| x == GUEST_SENTINEL) {
|
|
scopes.push(GUEST_SENTINEL.to_string());
|
|
}
|
|
scopes
|
|
}
|
|
|
|
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
|
|
/// to narrow the declared scopes to what the viewer's prompt promised.
|
|
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
|
|
|
|
pub fn has_raw_app_sdk_sentinel(scopes: Option<&[String]>) -> bool {
|
|
scopes.is_some_and(|s| s.iter().any(|x| x == RAW_APP_SDK_SENTINEL))
|
|
}
|
|
|
|
/// Endpoints that run code the caller supplies or names by job id (the latter
|
|
/// with no ownership check). Their jobs get an unscoped credential as the viewer,
|
|
/// so reaching one would make a captured SDK token a full account takeover.
|
|
fn is_request_supplied_code_route(suffix: &str) -> bool {
|
|
// Prefixes, so the `_async` variants are covered too.
|
|
const CODE_ROUTES: [&str; 10] = [
|
|
"jobs/run/preview",
|
|
"jobs/run_inline/preview",
|
|
"jobs/run_wait_result/preview",
|
|
"jobs/run/preview_bundle",
|
|
"jobs/run/preview_flow",
|
|
"jobs/run_wait_result/preview_flow",
|
|
"jobs/run/dependencies",
|
|
"jobs/run/flow_dependencies",
|
|
"jobs/run/workflow_as_code",
|
|
"jobs/restart/f",
|
|
];
|
|
CODE_ROUTES.iter().any(|p| suffix.starts_with(p))
|
|
}
|
|
|
|
/// Routes an app embed token (sentinel) is denied. Its broad scopes (`apps:run`,
|
|
/// `jobs:read`, `users:read`, `folders:read`) exist only for a fixed set of routes a
|
|
/// running app uses, but the whole `/apps`, `/jobs`, `/users`, `/folders` routers are
|
|
/// CORS-enabled for the opaque app iframe. Default-deny those domains via an explicit
|
|
/// allowlist so the token can't reach workspace inventory, counts, exports, or
|
|
/// capability-minting routes (job signatures / resume URLs).
|
|
fn app_embed_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
|
|
match domain {
|
|
ScopeDomain::Apps => !app_embed_apps_route_allowed(suffix),
|
|
ScopeDomain::Jobs => !app_embed_job_route_allowed(suffix),
|
|
ScopeDomain::Users => suffix != "users/whoami",
|
|
ScopeDomain::Folders => suffix != "folders/listnames",
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// App routes a running app uses: its own definition (`apps/get/p/<path>`, further
|
|
/// path-scoped by `apps:read:<path>`) and the public app-serving endpoints
|
|
/// (`apps_u/*`: public_app, public_resource, get_data, and the path-taking
|
|
/// `execute_component` / `download_s3_file`, which re-check `apps:run|read:<path>`
|
|
/// in their handlers so they stay confined to this app). Everything else in the
|
|
/// domain — workspace app inventory (`exists`, `custom_path_exists`, `list`,
|
|
/// `list_paths*`, `secret_of`, history, management) — is denied.
|
|
fn app_embed_apps_route_allowed(suffix: &str) -> bool {
|
|
// The embed-token mint endpoints live under `apps_u/` but they create
|
|
// credentials. A running app never calls them — the trusted embedder session/JWT
|
|
// mints the token and hands it to the iframe — so deny them here, otherwise an
|
|
// app embed token could renew itself indefinitely past the 12h expiry.
|
|
if suffix.starts_with("apps_u/embed_token") {
|
|
return false;
|
|
}
|
|
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
|
|
}
|
|
|
|
/// Routes a guest session is denied: the app-embed allowlist, plus the embed-token
|
|
/// mint. A guest session is the *embedder* — the viewer's own browser rendering the
|
|
/// app page — not the app's own JS, and the page mints the iframe's token from it.
|
|
///
|
|
/// Everything else stays default-denied, so a guest reaches the app it was let in
|
|
/// for and nothing around it.
|
|
fn guest_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
|
|
if domain == ScopeDomain::Apps && suffix.starts_with("apps_u/embed_token") {
|
|
return false;
|
|
}
|
|
app_embed_route_denied(domain, suffix)
|
|
}
|
|
|
|
/// Job routes a running app uses (the by-id poll/cancel surface driven by the
|
|
/// frontend JobLoader). Everything else in the jobs domain — enumeration, counts,
|
|
/// exports, and the `job_signature`/`resume_urls` capability-minting routes — is
|
|
/// denied. By-id reads are further confined to the app's own runs by
|
|
/// `require_job_read_access` (the `app_embed` cutoff).
|
|
fn app_embed_job_route_allowed(suffix: &str) -> bool {
|
|
// `get_root_job_id` is intentionally absent: its handler has no access check at
|
|
// all (returns any job's root id by id) and the app never calls it, so denying
|
|
// it costs nothing and avoids leaking a foreign job's flow lineage.
|
|
const ALLOWED: [&str; 15] = [
|
|
"jobs_u/get/",
|
|
"jobs_u/getupdate/",
|
|
"jobs_u/getupdate_sse/",
|
|
"jobs_u/get_logs/",
|
|
"jobs_u/get_completed_logs_tail/",
|
|
"jobs_u/get_args/",
|
|
"jobs_u/get_flow/",
|
|
"jobs_u/get_flow_all_logs/",
|
|
"jobs_u/get_flow_debug_info/",
|
|
"jobs_u/get_log_file/",
|
|
"jobs_u/completed/get/",
|
|
"jobs_u/completed/get_result/",
|
|
"jobs_u/completed/get_result_maybe/",
|
|
"jobs_u/completed/get_timing/",
|
|
"jobs_u/queue/cancel/",
|
|
];
|
|
ALLOWED.iter().any(|p| suffix.starts_with(p))
|
|
}
|
|
|
|
/// Resource routes a metadata-only `resources:run` scope (app embed tokens) may
|
|
/// GET: pickers (`/list`) and type schemas. Excludes every value-returning route
|
|
/// (`get`, `get_value`, `get_value_interpolated`, `list_search`) so resource
|
|
/// values — which can hold credentials — are never exposed.
|
|
fn resource_metadata_route_allowed(suffix: &str) -> bool {
|
|
suffix == "resources/list"
|
|
|| suffix.starts_with("resources/list_names/")
|
|
|| suffix.starts_with("resources/exists/")
|
|
|| suffix.starts_with("resources/type/")
|
|
}
|
|
|
|
/// The `jobs:run` scopes a token's job reads are confined to, or `None` when they are
|
|
/// not confined to particular runnables.
|
|
///
|
|
/// A run scope is what the trigger UI mints per script or flow and hands to a webhook
|
|
/// caller / CI job: it may start the runnables it names and follow those runs, so its
|
|
/// by-id job reads must stay within what it can start (enforced by
|
|
/// `require_job_read_access`). Both the path (`jobs:run:flows:f/team/etl`) and the
|
|
/// kind-only (`jobs:run:scripts`, which legacy `jobs:runscript` tokens carry) forms
|
|
/// confine, since `ScopeDefinition::includes` already matches a candidate
|
|
/// `jobs:run:<kind>:<path>` against either.
|
|
///
|
|
/// Returns `None` — unconfined — when the token is effectively unscoped, or carries a
|
|
/// jobs scope that grants job reads in its own right: `jobs:read`/`jobs:write`, or a
|
|
/// bare `jobs:run` (it can start anything, so confining its reads to "what it may run"
|
|
/// would restrict nothing).
|
|
pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option<Vec<ScopeDefinition>> {
|
|
let mut confinement = Vec::new();
|
|
for scope in scopes?
|
|
.iter()
|
|
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
|
|
{
|
|
let Ok(scope) = ScopeDefinition::from_scope_string(scope) else {
|
|
continue;
|
|
};
|
|
if ScopeDomain::from_str(&scope.domain) != Some(ScopeDomain::Jobs) {
|
|
continue;
|
|
}
|
|
match ScopeAction::from_str(&scope.action) {
|
|
Some(ScopeAction::Run) if scope.kind.is_some() || scope.resource.is_some() => {
|
|
confinement.push(scope)
|
|
}
|
|
Some(_) => return None,
|
|
None => continue,
|
|
}
|
|
}
|
|
(!confinement.is_empty()).then_some(confinement)
|
|
}
|
|
|
|
/// Whether a job that ran `runnable_path` as `kind` (`scripts` or `flows`) is inside a
|
|
/// [`job_read_run_confinement`] set.
|
|
pub fn run_confinement_admits(
|
|
confinement: &[ScopeDefinition],
|
|
kind: &str,
|
|
runnable_path: &str,
|
|
) -> bool {
|
|
let required = ScopeDefinition::new(
|
|
ScopeDomain::Jobs.as_str(),
|
|
ScopeAction::Run.as_str(),
|
|
Some(kind),
|
|
Some(vec![runnable_path.to_string()]),
|
|
);
|
|
confinement.iter().any(|scope| scope.includes(&required))
|
|
}
|
|
|
|
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)))?;
|
|
|
|
// App embed tokens carry `resources:run`: metadata-only resource access via
|
|
// default-deny + allowlist (so a new value route is never exposed by accident).
|
|
// See `resource_metadata_route_allowed`.
|
|
if scope_domain == ScopeDomain::Resources && scope_action == ScopeAction::Run {
|
|
return Ok(required_action == ScopeAction::Read
|
|
&& route_path.is_some_and(resource_metadata_route_allowed));
|
|
}
|
|
|
|
// Apps `write` covers `run` (see `ScopeDefinition::includes`). Like every domain
|
|
// here this layer is resource-blind; the Run handlers path-check the app.
|
|
if scope_domain == ScopeDomain::Apps
|
|
&& scope_action == ScopeAction::Write
|
|
&& required_action == ScopeAction::Run
|
|
{
|
|
return Ok(true);
|
|
}
|
|
|
|
// `jobs:run` is a grant to *start* a runnable. The only reads it implies are the
|
|
// by-id routes a caller needs to follow the run it started
|
|
// (`RUN_WHITELISTED_GET_PATHS`) — never workspace-wide enumeration (`jobs/list`,
|
|
// counts, exports), which is what `jobs:read` is for. Those by-id reads are in turn
|
|
// confined to the runnable a path-scoped token names, by `require_job_read_access`.
|
|
// `ScopeAction::Run.includes(&Read)` (which exists so `apps:run` can fetch the app
|
|
// it runs) must not reach this domain, so decide it here rather than falling
|
|
// through to the hierarchy below.
|
|
if scope_domain == ScopeDomain::Jobs
|
|
&& scope_action == ScopeAction::Run
|
|
&& required_action == ScopeAction::Read
|
|
{
|
|
return Ok(route_path.is_some_and(|p| {
|
|
RUN_WHITELISTED_GET_PATHS
|
|
.iter()
|
|
.any(|path| p.starts_with(path))
|
|
}));
|
|
}
|
|
|
|
if !scope_action.includes(&required_action) {
|
|
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)
|
|
}
|
|
|
|
/// The workspace-less routes a job token (`$WM_TOKEN`) may still read. A route qualifies
|
|
/// only when it answers from the caller's own account, from the request body, or with
|
|
/// content identical for every workspace (the Hub proxy, the documentation) — never
|
|
/// naming another workspace, and never disclosing instance configuration. `usage` reads
|
|
/// the caller's own row; `email` and `allowed_domain_auto_invite` are derived from the
|
|
/// token itself and touch no table.
|
|
///
|
|
/// `settings/global/automate_username_creation` is the one instance setting on the list.
|
|
/// `get_global_setting` exempts a handful of keys from its own super-admin gate, that one
|
|
/// among them, so the boolean is already readable by every authenticated user; it is here
|
|
/// because the CLI reads it before creating a user during a git-sync push, which runs as a
|
|
/// job. The other ungated keys have no such caller, so they stay confined — being ungated
|
|
/// earns a key nothing on its own.
|
|
///
|
|
/// Deliberately absent, as each crosses that line: `users/list_invites` (returns the
|
|
/// workspace ids the identity was invited to), `users/tokens/list` (credential metadata
|
|
/// of the borrowed identity), `users/exists/{email}` (an oracle over arbitrary
|
|
/// addresses, not the caller's own), and `workspaces/list` / `workspaces/users`.
|
|
///
|
|
/// Read methods only — a mutating handler added on one of these paths must be
|
|
/// reconsidered rather than inherit the grant.
|
|
fn is_global_read_open_to_job_token(route_path: &str) -> bool {
|
|
matches!(
|
|
route_path,
|
|
"/api/users/whoami"
|
|
| "/api/users/email"
|
|
| "/api/users/usage"
|
|
| "/api/users/tutorial_progress"
|
|
| "/api/workspaces/allowed_domain_auto_invite"
|
|
| "/api/settings/global/automate_username_creation"
|
|
| "/api/docs/search"
|
|
| "/api/docs/page"
|
|
| "/api/integrations/hub/list"
|
|
| "/api/embeddings/query_hub_scripts"
|
|
) || route_path.starts_with("/api/scripts/hub/")
|
|
|| route_path.starts_with("/api/flows/hub/")
|
|
|| route_path.starts_with("/api/apps/hub/")
|
|
}
|
|
|
|
/// The workspace-less POSTs a job token keeps. Each takes a `POST` for the sake of a
|
|
/// request body rather than to commit anything of consequence: none writes Windmill state
|
|
/// outside the caller's own account. The object-storage probe does write to the store the
|
|
/// body names — see its entry. What each may *read* is bounded per entry below — the
|
|
/// workspace-existence check answers for any id, the rest only from the body or the
|
|
/// caller's own row:
|
|
/// - a resource editor's object-storage "Test connection" runs as a preview job that POSTs
|
|
/// the storage config (`TestConnection.svelte`). It puts and deletes an object to prove
|
|
/// the credentials work, so it does write — but only to the store the body names, and a
|
|
/// failure between the two can leave that object behind. No Windmill state of any
|
|
/// workspace is touched.
|
|
/// - `wmill workspace add`, how a job points the CLI at its own instance, checks the
|
|
/// workspace exists before accepting the credentials — the git-sync hub scripts run
|
|
/// exactly this. `workspace` carries no row-level security, so the bare boolean it
|
|
/// answers is instance-wide rather than membership-filtered; what it discloses is
|
|
/// only whether a workspace id is taken.
|
|
/// - the cron preview computes the next occurrences of the expression in the body. It
|
|
/// takes no `ApiAuthed` and opens no transaction, so it returns nothing the caller did
|
|
/// not send.
|
|
/// - tutorial progress upserts a UI bitfield keyed on the caller's own email. Its path
|
|
/// serves a `GET` too, which the read list above carries.
|
|
const GLOBAL_WRITES_OPEN_TO_JOB_TOKEN: [&str; 4] = [
|
|
"/api/settings/test_object_storage_config",
|
|
"/api/workspaces/exists",
|
|
"/api/schedules/preview",
|
|
"/api/users/tutorial_progress",
|
|
];
|
|
|
|
/// Confines a job token (`$WM_TOKEN`) to routes that name a workspace. It is minted
|
|
/// for one job in one workspace yet carries that job's full user privileges, so on an
|
|
/// instance-wide route it would mint a permanent workspace-less API token, read
|
|
/// worker-group configuration, or manage global users. The token lookup already
|
|
/// rejects a workspace-bound API token on those routes; this is the same rule for
|
|
/// job tokens.
|
|
///
|
|
/// Keyed on the job token specifically: an MCP token is workspace-bound too, but
|
|
/// deliberately publishes instance-wide tools. Callers pass only routes whose path
|
|
/// carries no workspace.
|
|
pub fn check_job_token_for_global_route(route_path: &str, http_method: &str) -> Result<()> {
|
|
let is_read = map_http_method_to_action(http_method, route_path) == ScopeAction::Read;
|
|
if (is_read && is_global_read_open_to_job_token(route_path))
|
|
|| (http_method.eq_ignore_ascii_case("POST")
|
|
&& GLOBAL_WRITES_OPEN_TO_JOB_TOKEN.contains(&route_path))
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(Error::PermissionDenied(format!(
|
|
"A job token ($WM_TOKEN) is confined to the workspace of its job and cannot be used \
|
|
on {route_path}, which is not workspace-scoped. Use an API token created for the \
|
|
user instead."
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// 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(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// The minimal scope string that grants access to exactly `{method} {path}`, as
|
|
/// `check_route_access` would require it. Used to mint a least-privilege JWT for
|
|
/// a single proxied request (the MCP endpoint proxy), so the minted token can do
|
|
/// only that one operation rather than acting as a blank check.
|
|
///
|
|
/// `path` is the request path (e.g. `/api/w/{workspace}/variables/get/...`).
|
|
/// Returns `None` if the route's domain can't be determined — the caller should
|
|
/// then fail closed.
|
|
pub fn scope_for_route(method: &str, path: &str) -> Option<String> {
|
|
let action = map_http_method_to_action(method, path);
|
|
let (domain, kind, _suffix) = extract_domain_from_route(path).ok()?;
|
|
Some(match (domain, action, kind) {
|
|
(ScopeDomain::Jobs, ScopeAction::Run, Some(kind)) => format!("jobs:run:{}", kind),
|
|
(domain, action, _) => format!("{}:{}", domain.as_str(), action.as_str()),
|
|
})
|
|
}
|
|
|
|
/// 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 data_metrics_is_its_own_domain_not_a_scripts_alias() {
|
|
// `data_metrics` must be a distinct domain: a token scoped to it must reach
|
|
// only the data_metrics route, never the broader /scripts routes (some of
|
|
// which do no further scope check). Regression for a privilege escalation.
|
|
assert_eq!(
|
|
ScopeDomain::from_str("data_metrics"),
|
|
Some(ScopeDomain::DataMetrics)
|
|
);
|
|
let dm = vec!["data_metrics:read".to_string()];
|
|
assert!(check_route_access(&dm, "/api/w/test/data_metrics/list", "GET").is_ok());
|
|
assert!(check_route_access(&dm, "/api/w/test/scripts/list", "GET").is_err());
|
|
assert!(check_route_access(&dm, "/api/w/test/scripts/raw/h/abc.ts", "GET").is_err());
|
|
// Conversely a scripts token does not reach the data_metrics route.
|
|
let sc = vec!["scripts:read".to_string()];
|
|
assert!(check_route_access(&sc, "/api/w/test/data_metrics/list", "GET").is_err());
|
|
}
|
|
|
|
/// `apps_u/execute_component` (and the S3 upload the same components drive) is a
|
|
/// Run action, so a scoped token needs `apps:run`. `apps:write` must keep reaching
|
|
/// it too: it can rewrite the app and its policy, so withholding execution from it
|
|
/// protects nothing while breaking every app-scoped token.
|
|
#[test]
|
|
fn apps_run_routes_accept_run_and_write_scopes() {
|
|
let execute = "/api/w/test/apps_u/execute_component/u/admin/app";
|
|
for scope in ["apps:run", "apps:write"] {
|
|
assert!(
|
|
check_route_access(&[scope.to_string()], execute, "POST").is_ok(),
|
|
"{scope} must reach execute_component"
|
|
);
|
|
}
|
|
assert!(check_route_access(&["apps:read".to_string()], execute, "POST").is_err());
|
|
// The write-satisfies-run allowance is confined to the apps domain.
|
|
assert!(check_route_access(
|
|
&["jobs:write".to_string()],
|
|
"/api/w/test/jobs/run/p/u/admin/script",
|
|
"POST"
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn jobs_run_reads_are_limited_to_the_by_id_poll_routes() {
|
|
let job = "/api/w/test/jobs_u/completed/get_result/019ff012-6b1e-0d6b-fc0d-0c85d34d9cec";
|
|
let list = "/api/w/test/jobs/list";
|
|
for scope in ["jobs:run", "jobs:run:scripts:u/admin/script"] {
|
|
// Following the run it started stays available...
|
|
assert!(
|
|
check_route_access(&[scope.to_string()], job, "GET").is_ok(),
|
|
"{scope} must reach the by-id job poll routes"
|
|
);
|
|
// ...but a run grant is not a licence to enumerate the workspace's jobs.
|
|
assert!(
|
|
check_route_access(&[scope.to_string()], list, "GET").is_err(),
|
|
"{scope} must not reach jobs/list"
|
|
);
|
|
}
|
|
assert!(check_route_access(&["jobs:read".to_string()], list, "GET").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn run_scopes_confine_job_reads_by_kind_and_path() {
|
|
let confinement =
|
|
job_read_run_confinement(Some(&["jobs:run:flows:f/team/*".to_string()])).unwrap();
|
|
assert!(run_confinement_admits(&confinement, "flows", "f/team/etl"));
|
|
// Right path, wrong kind — a script named like the flow is not the flow.
|
|
assert!(!run_confinement_admits(
|
|
&confinement,
|
|
"scripts",
|
|
"f/team/etl"
|
|
));
|
|
assert!(!run_confinement_admits(
|
|
&confinement,
|
|
"flows",
|
|
"f/other/etl"
|
|
));
|
|
|
|
// A kind-only scope confines to that kind, at any path.
|
|
let kind_only = job_read_run_confinement(Some(&["jobs:run:scripts".to_string()])).unwrap();
|
|
assert!(run_confinement_admits(
|
|
&kind_only,
|
|
"scripts",
|
|
"u/admin/anything"
|
|
));
|
|
assert!(!run_confinement_admits(&kind_only, "flows", "f/team/etl"));
|
|
|
|
// Scopes that grant job reads in their own right leave reads unconfined.
|
|
for scopes in [
|
|
vec!["jobs:read".to_string()],
|
|
vec!["jobs:run".to_string()],
|
|
vec![
|
|
"jobs:run:scripts:u/admin/script".to_string(),
|
|
"jobs:read".to_string(),
|
|
],
|
|
vec!["if_jobs:filter_tags:deno".to_string()],
|
|
] {
|
|
assert!(
|
|
job_read_run_confinement(Some(&scopes)).is_none(),
|
|
"{scopes:?} must not confine job reads"
|
|
);
|
|
}
|
|
assert!(job_read_run_confinement(None).is_none());
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
// Whole-collection reads (the workspace export, `apps:read`, ...) require the
|
|
// domain with no path. Only a grant spanning every path may satisfy that.
|
|
#[test]
|
|
fn test_unqualified_requirement_needs_a_whole_domain_grant() {
|
|
let unqualified = ScopeDefinition::new("resources", "read", None, None);
|
|
|
|
let wildcard = ScopeDefinition::new("resources", "read", None, Some(vec!["*".to_string()]));
|
|
assert!(wildcard.includes(&unqualified));
|
|
|
|
let path_scoped = ScopeDefinition::new(
|
|
"resources",
|
|
"read",
|
|
None,
|
|
Some(vec!["f/team/db".to_string(), "u/alice/db".to_string()]),
|
|
);
|
|
assert!(!path_scoped.includes(&unqualified));
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
#[test]
|
|
fn test_scope_for_route() {
|
|
// The minted scope must be exactly what check_route_access requires for
|
|
// the same route, so a JWT carrying it passes for that one route only.
|
|
assert_eq!(
|
|
scope_for_route("GET", "/api/w/ws/variables/get/u/x/y").as_deref(),
|
|
Some("variables:read")
|
|
);
|
|
assert_eq!(
|
|
scope_for_route("POST", "/api/w/ws/variables/create").as_deref(),
|
|
Some("variables:write")
|
|
);
|
|
assert_eq!(
|
|
scope_for_route("DELETE", "/api/w/ws/resources/delete/u/x/y").as_deref(),
|
|
Some("resources:write")
|
|
);
|
|
// jobs run paths carry the runnable kind.
|
|
assert_eq!(
|
|
scope_for_route("POST", "/api/w/ws/jobs/run/p/u/x/y").as_deref(),
|
|
Some("jobs:run:scripts")
|
|
);
|
|
assert_eq!(
|
|
scope_for_route("POST", "/api/w/ws/jobs/run/f/u/x/y").as_deref(),
|
|
Some("jobs:run:flows")
|
|
);
|
|
|
|
// Preview/bundle runs have no deployed path and their handlers require the
|
|
// broad `jobs:run` scope, so the derived scope must not carry a kind.
|
|
for path in [
|
|
"/api/w/ws/jobs/run/preview",
|
|
"/api/w/ws/jobs/run/preview_bundle",
|
|
"/api/w/ws/jobs/run/preview_flow",
|
|
"/api/w/ws/jobs/run_wait_result/preview",
|
|
"/api/w/ws/jobs/run_wait_result/preview_flow",
|
|
] {
|
|
assert_eq!(
|
|
scope_for_route("POST", path).as_deref(),
|
|
Some("jobs:run"),
|
|
"preview route {path} must derive the broad jobs:run scope"
|
|
);
|
|
}
|
|
|
|
// By-path runs of a deployed runnable whose path contains "preview" must
|
|
// still derive their kind (not be swept into the broad jobs:run above),
|
|
// otherwise a `jobs:run:scripts:*`/`jobs:run:flows:*` token is denied.
|
|
assert_eq!(
|
|
scope_for_route("POST", "/api/w/ws/jobs/run/p/u/alice/preview_report").as_deref(),
|
|
Some("jobs:run:scripts")
|
|
);
|
|
assert_eq!(
|
|
scope_for_route(
|
|
"POST",
|
|
"/api/w/ws/jobs/run_wait_result/p/f/team/preview_report"
|
|
)
|
|
.as_deref(),
|
|
Some("jobs:run:scripts")
|
|
);
|
|
assert_eq!(
|
|
scope_for_route("POST", "/api/w/ws/jobs/run/f/f/team/preview_report").as_deref(),
|
|
Some("jobs:run:flows")
|
|
);
|
|
|
|
// The minted scope actually satisfies the route check it targets.
|
|
let s = scope_for_route("POST", "/api/w/ws/variables/create").unwrap();
|
|
assert!(check_route_access(&[s], "/api/w/ws/variables/create", "POST").is_ok());
|
|
|
|
// Unknown route -> None so the caller fails closed.
|
|
assert!(scope_for_route("GET", "/healthz").is_none());
|
|
}
|
|
}
|