mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
* feat: cap user token expiration with an instance setting Adds `max_token_expiration_days`, an instance-wide ceiling on how far ahead a token created through `POST /users/tokens/create` may expire. With it set, that route refuses a token with no expiration and one that expires past the window; absent or non-positive, nothing changes. Only the user-facing handler enforces it. Server-side mints (native trigger webhook tokens, app embed tokens, sessions) pick a lifetime the caller never chooses and go straight to `create_token_internal`, so they stay uncapped, as does the superadmin `impersonate` route. Service accounts are exempt, in the workspace the token targets or in any workspace for a global token, so unattended automation can keep longer-lived credentials. The token form now surfaces the API error instead of only logging it, and offers "Expires In" in MCP mode as well: that mode always sent no expiration, which the cap refuses, leaving MCP URLs impossible to generate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: shorten over-long token expirations instead of refusing them Refusing a non-compliant request breaks the callers that cannot comply. The CLI authorization page, `wmill user create-token` and the editor's language-server token each pick a lifetime — usually none at all — with no way to read the setting, so a cap made browser login hang and the editor lose its LSP root rather than stopping the long-lived tokens the setting is aimed at. `cap_token_expiration` now returns the expiration to store, shortening a request that asks for too long or for none. The policy still holds absolutely, no caller can break, and there is no clock-skew boundary where an expiration exactly at the ceiling flips to an error. The token form needed no changes at all, so its MCP and error-toast edits are gone with it. Also drops the Enterprise badge on the setting, which nothing enforced, notes the mint paths in docs/auth-surface.md, and pins that `tokens/impersonate` and the second-workspace case stay outside the exemption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: drop the unintended token-form change and correct the exemption docs The token form needed no change once the ceiling shortens rather than refuses, but the earlier revert restored from the index, which already held the staged edit, so the MCP expiration field and the error toast stayed on the branch with a comment justifying them by a refusal that no longer happens. docs/auth-surface.md claimed a service-account row in any workspace exempts outright; that only holds for a workspace-less token, which has no workspace to match. A ceiling written as a string, which the YAML instance config and config sync can both produce, now has a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: offer only expirations within the ceiling in the token form The server shortens a token that asks for longer than `max_token_expiration_days` or for no expiration, which the token form could not tell anyone: a user picking "No expiration" got the ceiling silently. The form now reads the setting and, with one set, drops "No expiration" and every choice above it, adds the ceiling itself as "N days (maximum)" and selects it, and says the instance limits tokens to N days. MCP mode hides the expiration field and always sent none, so with a ceiling the field now shows there too and keeps its value across the toggle. Without a ceiling the form is unchanged. Reading it needs no superadmin: the setting joins the keys any logged-in user can read through `GET /settings/global/{key}`. It holds a policy, not a secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: make the token form and the server agree on what counts as a ceiling The form parsed `max_token_expiration_days` more loosely than `cap_token_expiration`, so the two could disagree on whether a ceiling exists at all. A `7.0` from the YAML instance config, or a string such as "7.0" or "1e1", made the form hide "No expiration" and announce a 7-day limit while the server capped nothing; a value between chrono's and JavaScript's date limits preselected an expiration the server could not parse. Both now read the same thing as a ceiling: a whole number of days from 1 to 1,000,000, stored as an integer, an integral float or a string of digits. `parseMaxTokenExpirationDays` holds the frontend's copy, and the instance settings validation uses it too, so the settings page no longer accepts a value the server would ignore. The bound replaces the date-range guard on both sides. Also corrects the rationale for shortening rather than refusing: the setting is now readable by any logged-in user, so those callers do not read it rather than cannot, and CLIs already installed never will. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: cap service-account tokens like everyone else's The ticket exempted service accounts from `max_token_expiration_days`, but their tokens are the long-lived ones a rotation policy is meant to bound, and the exemption let any workspace admin get an uncapped token by impersonating one. It also left the token form unable to agree with the server: an admin impersonating a service account was offered only capped choices while the server would have kept any. `cap_token_expiration` now takes just the requested expiration, with no per-caller lookup, and the service-account query and its cache entry are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: cap superadmin impersonation tokens and pin the frontend parser `POST /users/tokens/impersonate` wrote its own token row with whatever expiration the superadmin sent, so it was the one route left that could mint a token that never expires with `max_token_expiration_days` set. The ceiling only decides the stored expiration (the auth lookup never reads the setting), so leaving it uncapped meant exactly that. It now goes through `cap_token_expiration` like `create_token`; nothing in Windmill calls it, so no caller changes. Also adds `tokenExpiration.test.ts`, pinning which stored values `parseMaxTokenExpirationDays` reads as a ceiling against the server's reading, and documents that tokens existing when the setting is turned on or lowered keep their expiration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reject a max_token_expiration_days the token routes cannot read The settings API and config sync stored any value for the key, and the token routes can only read an unparseable one as no ceiling. A typo such as `7.5` or "7.0" was accepted and silently turned the policy off. `parse_max_token_expiration_days` in windmill-common is now the single server reading of the setting: null or empty clears it, a whole number of days within the bound is the ceiling, anything else is an error. The settings write hook and `sync_global_settings_declarative` reject that error, and `cap_token_expiration` reads through the same function, logging a value written around both. Tests: the parser's accept/clear/reject table (the same table as the frontend parser's), the settings API refusing 7.5, and config sync refusing "7.0". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reserve the CLI login token label so its expiry does not email the user With a token expiration ceiling, the token the CLI authorization page mints now expires, so every `wmill` login earned an "expiring soon" and an "expired and deleted" email and critical alert. The CLI already signs in again on its own when that token stops working, so those notifications ask the user to do nothing. The page now labels it `cli-login:<username>` (previously `cli-<username>`), reserved in `is_user_token` and its SQL and Svelte mirrors: no expiry notifications, and the label cannot be edited. A colon-terminated namespace like `embed_app:` and `impersonation:` keeps hand-made labels clear of it. Not in `is_server_minted_label`, since the page mints through `/users/tokens/create`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: skip the expiring-soon warning for tokens that were short-lived from the start A token whose whole lifetime fits in the 7-day warning window got its "expiring soon" email (and critical alert, when enabled) minutes after it was created, about a lifetime its creator had just picked. With an expiration ceiling of 7 days or less that is every token created from the form or `wmill token create`. `register_token_expiry_notification` no longer queues a warning for such a token. The window is now `TOKEN_EXPIRY_WARNING_DAYS`, shared with `check_expiring_tokens`, so shortening the warning window can never leave tokens of an intermediate lifetime with no warning at all. The "expired and deleted" notice still goes out for every user token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: exempt service-account tokens from the expiration ceiling again Service accounts are the identity automation that needs a long-lived credential runs as, so their tokens are exempt from `max_token_expiration_days` once more: a service account in the workspace the token names, or in any workspace for a workspace-less token. `tokens/impersonate` checks the impersonated account, so a superadmin minting a token for a service account gets the same exemption. The token form applies the same rule for the account it is running as, when that account is a service account in the current workspace, which is what an admin impersonating one sees; otherwise it would offer only capped choices while the server keeps any. Any workspace admin can create and impersonate a service account to hold an uncapped token, so the ceiling bounds personal tokens; the doc comment and docs/auth-surface.md say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: decide the token form's service-account exemption from the token's workspace The form treated the account as a service account only when it was one in the workspace the app was on, while the server checks the workspace the token is for, or any workspace for a workspace-less token. With an email that is a service account in one workspace and an ordinary member of another, picking the other workspace in MCP mode offered "No expiration" and the server silently stored the ceiling; the reverse hid the exemption. `GET /workspaces/users` now returns each membership's `is_service_account` (its query already joins the `usr` row), and the form applies the server's rule to the token's own workspace. The selection becomes a derived value held within the ceiling, so switching to a capped workspace never leaves an unoffered choice selected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2005 lines
76 KiB
Rust
2005 lines
76 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.
|
|
*/
|
|
|
|
pub mod auth;
|
|
#[cfg(feature = "private")]
|
|
pub mod ee;
|
|
pub mod ee_oss;
|
|
pub mod scopes;
|
|
|
|
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
|
|
use http::request::Parts;
|
|
|
|
use windmill_audit::audit_oss::AuditAuthorable;
|
|
use windmill_common::{
|
|
auth::{
|
|
fetch_authed_from_permissioned_as, hash_token, is_devops_email, is_super_admin_email,
|
|
TOKEN_PREFIX_LEN,
|
|
},
|
|
db::{Authable, Authed, AuthedRef},
|
|
error::{self, Error, Result},
|
|
jobs::JobTriggerKind,
|
|
triggers::TriggerMetadata,
|
|
users::username_to_permissioned_as,
|
|
DB,
|
|
};
|
|
|
|
use scopes::ScopeDefinition;
|
|
|
|
// Re-export key auth types and functions
|
|
pub use auth::{
|
|
get_end_user_email, invalidate_token_from_cache, is_no_auth, AuthCache, ExpiringAuthCache,
|
|
OptTokened, Tokened, TruncatedTokenWithEmail, AUTH_CACHE,
|
|
};
|
|
|
|
// ------------ ApiAuthed & OptJobAuthed types ------------
|
|
|
|
/// Prefix `username_override_from_label` puts on the label of a generic user token. The
|
|
/// override keeps this form even though `display_username` skips it: `require_job_read_access`
|
|
/// matches it against `created_by` to let a token re-read the jobs it launched.
|
|
pub const GENERIC_TOKEN_LABEL_PREFIX: &str = "label-";
|
|
|
|
#[derive(Default, Clone, Debug)]
|
|
pub struct OptJobAuthed {
|
|
pub job_id: Option<uuid::Uuid>,
|
|
pub authed: ApiAuthed,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Hash, Eq, PartialEq)]
|
|
pub struct ApiAuthed {
|
|
pub email: String,
|
|
pub username: String,
|
|
pub is_admin: bool,
|
|
pub is_operator: bool,
|
|
pub groups: Vec<String>,
|
|
// (folder name, can write, is owner)
|
|
pub folders: Vec<(String, bool, bool)>,
|
|
pub scopes: Option<Vec<String>>,
|
|
pub username_override: Option<String>,
|
|
/// Whether `username_override` is a generic user-token label rather than a name that
|
|
/// identifies the requester. It cannot be recovered from the value: the ephemeral
|
|
/// end-user override passes a `created_by` through verbatim, and that may itself be a
|
|
/// `label-*` string. Only `username_override_from_label` sets it.
|
|
pub username_override_is_token_label: bool,
|
|
/// Whether the request authenticated with the session token minted at browser login.
|
|
/// Read by `trigger_or_fallback` and by `TriggerSource::of_request` (which attributes a
|
|
/// trigger mutation to the UI) — see `is_session_label` for why it attributes rather than
|
|
/// proves, and must not gate authority.
|
|
pub is_session_token: bool,
|
|
pub token_prefix: Option<String>,
|
|
pub read_only: bool,
|
|
/// Set when this authed was resolved from a job's `WM_TOKEN`. Such a token's
|
|
/// identity is derived from an app/flow `on_behalf_of` that a `wm_deployers`
|
|
/// member can point at a superadmin, so it must never be trusted as a global
|
|
/// superadmin (`require_super_admin`), GHSA-hfh4-cx4h-3fcr.
|
|
pub job_id: Option<uuid::Uuid>,
|
|
/// When this credential itself expires, if it carries its own expiry rather than a
|
|
/// token row. Set for a guest JWT (its `exp`): a token minted from it is capped at
|
|
/// this, since the JWT's expiry is a guest's only revocation and there is no row to
|
|
/// look the limit up in. `None` for every credential whose limit lives in `token`.
|
|
pub credential_expiry: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|
|
|
|
impl ApiAuthed {
|
|
pub fn to_authed_ref<'e>(&'e self) -> AuthedRef<'e> {
|
|
AuthedRef {
|
|
email: &self.email,
|
|
username: &self.username,
|
|
is_admin: &self.is_admin,
|
|
is_operator: &self.is_operator,
|
|
groups: &self.groups,
|
|
folders: &self.folders,
|
|
scopes: &self.scopes,
|
|
token_prefix: &self.token_prefix,
|
|
}
|
|
}
|
|
|
|
/// The name a run triggered by this principal is credited to (`v2_job.created_by`). A
|
|
/// trigger-token override names the entity that fired the request and wins; a generic
|
|
/// token label does not, so the token owner is credited and stays traceable even when
|
|
/// `permissioned_as` is an on-behalf-of identity. The audit `end_user` is the override
|
|
/// itself, label included, so the two diverge for a labeled token.
|
|
pub fn display_username(&self) -> &str {
|
|
match self.username_override.as_deref() {
|
|
Some(o) if !self.username_override_is_token_label => o,
|
|
_ => &self.username,
|
|
}
|
|
}
|
|
|
|
/// Set an override that names the entity acting, e.g. a trigger. Assigning
|
|
/// `username_override` on its own would keep the provenance flag of whatever this authed
|
|
/// was built from, and a stale `true` makes `display_username` ignore the new value.
|
|
pub fn set_acting_username_override(&mut self, username_override: Option<String>) {
|
|
self.username_override = username_override;
|
|
self.username_override_is_token_label = false;
|
|
}
|
|
|
|
/// The `trigger_kind` a run started through a `/jobs/run*` route is stamped with: a trigger
|
|
/// that built its own metadata always wins, and a run driven by any other token — webhooks,
|
|
/// the CLI, the SDKs — is `webhook`, matching the `wm_trigger.kind` the preprocessor already
|
|
/// reports for these routes. Derived from the token, never from the request, because the
|
|
/// column is authority-bearing for other kinds (`app` marks a file as app-produced).
|
|
///
|
|
/// A browser session is left unstamped rather than marked [`JobTriggerKind::Ui`]: that label
|
|
/// is one a worker built before this release cannot decode, and it would strand the jobs
|
|
/// carrying it. `webhook` has always been decodable, so it is safe to write today.
|
|
pub fn trigger_or_fallback(&self, trigger: Option<TriggerMetadata>) -> Option<TriggerMetadata> {
|
|
if trigger.is_some() {
|
|
return trigger;
|
|
}
|
|
if self.is_session_token {
|
|
return None;
|
|
}
|
|
Some(TriggerMetadata::new(None, JobTriggerKind::Webhook))
|
|
}
|
|
}
|
|
|
|
impl From<ApiAuthed> for Authed {
|
|
fn from(value: ApiAuthed) -> Self {
|
|
Self {
|
|
email: value.email,
|
|
username: value.username,
|
|
is_admin: value.is_admin,
|
|
is_operator: value.is_operator,
|
|
groups: value.groups,
|
|
folders: value.folders,
|
|
scopes: value.scopes,
|
|
token_prefix: value.token_prefix,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<Authed> for ApiAuthed {
|
|
fn from(value: Authed) -> Self {
|
|
Self {
|
|
email: value.email,
|
|
username: value.username,
|
|
is_admin: value.is_admin,
|
|
is_operator: value.is_operator,
|
|
groups: value.groups,
|
|
folders: value.folders,
|
|
scopes: value.scopes,
|
|
username_override: None,
|
|
username_override_is_token_label: false,
|
|
is_session_token: false,
|
|
token_prefix: value.token_prefix,
|
|
read_only: false,
|
|
job_id: None,
|
|
credential_expiry: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AuditAuthorable for ApiAuthed {
|
|
fn username(&self) -> &str {
|
|
self.username.as_str()
|
|
}
|
|
fn email(&self) -> &str {
|
|
self.email.as_str()
|
|
}
|
|
fn username_override(&self) -> Option<&str> {
|
|
self.username_override.as_deref()
|
|
}
|
|
fn token_prefix(&self) -> Option<&str> {
|
|
self.token_prefix.as_deref()
|
|
}
|
|
}
|
|
|
|
impl Authable for ApiAuthed {
|
|
fn is_admin(&self) -> bool {
|
|
self.is_admin
|
|
}
|
|
|
|
fn is_operator(&self) -> bool {
|
|
self.is_operator
|
|
}
|
|
|
|
fn groups(&self) -> &[String] {
|
|
&self.groups
|
|
}
|
|
|
|
fn folders(&self) -> &[(String, bool, bool)] {
|
|
&self.folders
|
|
}
|
|
|
|
fn scopes(&self) -> Option<&[std::string::String]> {
|
|
self.scopes.as_ref().map(|x| x.as_slice())
|
|
}
|
|
|
|
fn email(&self) -> &str {
|
|
&self.email
|
|
}
|
|
|
|
fn username(&self) -> &str {
|
|
&self.username
|
|
}
|
|
}
|
|
|
|
// ------------ McpAuth impl (feature-gated) ------------
|
|
|
|
#[cfg(feature = "mcp")]
|
|
impl windmill_mcp::server::McpAuth for ApiAuthed {
|
|
fn username(&self) -> &str {
|
|
&self.username
|
|
}
|
|
|
|
fn email(&self) -> &str {
|
|
&self.email
|
|
}
|
|
|
|
fn is_admin(&self) -> bool {
|
|
self.is_admin
|
|
}
|
|
|
|
fn is_operator(&self) -> bool {
|
|
self.is_operator
|
|
}
|
|
|
|
fn groups(&self) -> &[String] {
|
|
&self.groups
|
|
}
|
|
|
|
fn folders(&self) -> &[(String, bool, bool)] {
|
|
&self.folders
|
|
}
|
|
|
|
fn scopes(&self) -> Option<&[String]> {
|
|
self.scopes.as_deref()
|
|
}
|
|
|
|
fn read_only(&self) -> bool {
|
|
self.read_only
|
|
}
|
|
}
|
|
|
|
// ------------ Utility functions ------------
|
|
|
|
/// Assert the *email* belongs to a superadmin. Prefer [`require_super_admin`],
|
|
/// which also rejects job tokens (`WM_TOKEN`); use this only where no `ApiAuthed`
|
|
/// is available and the caller has separately guaranteed it is not a job token.
|
|
pub async fn require_super_admin_email(db: &DB, email: &str) -> error::Result<()> {
|
|
let is_admin = is_super_admin_email(db, email).await?;
|
|
|
|
if !is_admin {
|
|
Err(Error::NotAuthorized(
|
|
"This endpoint requires the caller to be a super admin".to_owned(),
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Assert the caller is a superadmin acting under their own credentials.
|
|
///
|
|
/// A job's `WM_TOKEN` runs as the runnable's `on_behalf_of` identity, which a
|
|
/// non-superadmin `wm_deployers` member can point at a superadmin — so a job
|
|
/// token must never satisfy a global superadmin gate regardless of whose email
|
|
/// it carries (GHSA-hfh4-cx4h-3fcr). A real superadmin needing this from a script
|
|
/// uses a dedicated superadmin token instead of `$WM_TOKEN`.
|
|
pub async fn require_super_admin(db: &DB, authed: &ApiAuthed) -> error::Result<()> {
|
|
if authed.job_id.is_some() {
|
|
return Err(Error::NotAuthorized(
|
|
"This endpoint cannot be called with a job token ($WM_TOKEN). If a script \
|
|
genuinely needs to do this, create a dedicated superadmin token from the User \
|
|
settings drawer (the 'Tokens' section), store it as a secret, and use that token \
|
|
explicitly instead of $WM_TOKEN."
|
|
.to_owned(),
|
|
));
|
|
}
|
|
require_super_admin_email(db, &authed.email).await
|
|
}
|
|
|
|
/// Job-token-aware superadmin predicate for the many boolean `is_super_admin_email`
|
|
/// authorization branches (workspace deletion, fork drops, SSRF exemptions, ...).
|
|
/// A job's `WM_TOKEN` is never a superadmin regardless of whose email it carries
|
|
/// (GHSA-hfh4-cx4h-3fcr), so callers naturally fall through to the restricted path.
|
|
pub async fn is_super_admin_authed(db: &DB, authed: &ApiAuthed) -> error::Result<bool> {
|
|
if authed.job_id.is_some() {
|
|
return Ok(false);
|
|
}
|
|
is_super_admin_email(db, &authed.email).await
|
|
}
|
|
|
|
/// Instance-global admin predicate, job-token-aware. `ApiAuthed::is_admin` is a
|
|
/// *workspace*-admin claim (also true for superadmins), and a `WM_TOKEN` is capped
|
|
/// at workspace admin (GHSA-hfh4-cx4h-3fcr). Routes with no workspace binding that
|
|
/// treat `is_admin` as instance authorization (worker-group config, arbitrary
|
|
/// workspace unarchive, global concurrency pruning) must use this instead of the
|
|
/// raw `authed.is_admin`, so a job token can't wield a workspace-admin claim as an
|
|
/// instance action. Interactive admins are unaffected.
|
|
pub fn is_instance_admin(authed: &ApiAuthed) -> bool {
|
|
authed.is_admin && authed.job_id.is_none()
|
|
}
|
|
|
|
/// Hard-gate variant of [`is_instance_admin`] for instance-global routes: rejects
|
|
/// a job token (`WM_TOKEN`) explicitly, then requires admin.
|
|
pub fn require_instance_admin(authed: &ApiAuthed) -> error::Result<()> {
|
|
if authed.job_id.is_some() {
|
|
return Err(Error::NotAuthorized(
|
|
"This endpoint cannot be called with a job token ($WM_TOKEN): it is an \
|
|
instance-global admin action and a job token is capped at workspace admin. \
|
|
If a script genuinely needs this, create a dedicated token from the User \
|
|
settings drawer and use it explicitly instead of $WM_TOKEN."
|
|
.to_owned(),
|
|
));
|
|
}
|
|
if !authed.is_admin {
|
|
return Err(Error::RequireAdmin(authed.username.clone()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Forbid sensitive global user/token management when authenticated as a
|
|
/// superadmin *via a job token* (`WM_TOKEN`).
|
|
///
|
|
/// A `WM_TOKEN`'s identity is derived from an app/flow `on_behalf_of`, which a
|
|
/// non-admin `wm_deployers` member can point at a superadmin. Trusting it for
|
|
/// these operations would let them establish *persistent* superadmin (promote a
|
|
/// user, reset a superadmin's password, mint a superadmin token, ...). `job_id`
|
|
/// is set only for `WM_TOKEN`s; regular session/API tokens have it `None`, so a
|
|
/// real superadmin who needs this from a script uses a dedicated superadmin API
|
|
/// token (which only a real superadmin can create) instead of `$WM_TOKEN`.
|
|
pub async fn forbid_superadmin_job_token(
|
|
db: &DB,
|
|
email: &str,
|
|
job_id: Option<uuid::Uuid>,
|
|
) -> error::Result<()> {
|
|
if job_id.is_some() && is_super_admin_email(db, email).await? {
|
|
return Err(Error::NotAuthorized(
|
|
"This operation cannot be performed with a job token ($WM_TOKEN) that runs as a \
|
|
superadmin. If a script genuinely needs to do this, create a dedicated superadmin \
|
|
token from the User settings drawer (the 'Tokens' section), store it as a secret, \
|
|
and use that token explicitly instead of $WM_TOKEN."
|
|
.to_owned(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Forbid *minting a durable credential* from a job token that carries an elevated
|
|
/// instance identity (superadmin or `devops`; [`is_devops_email`] covers both).
|
|
///
|
|
/// The gates that cap `$WM_TOKEN` key off `ApiAuthed::job_id`, which only a job
|
|
/// token carries. A token minted from one is an ordinary database-backed token with
|
|
/// no such provenance, so it passes every one of those gates by email alone — the
|
|
/// cap would last only until the script exchanged its token for a fresh one
|
|
/// (GHSA-hfh4-cx4h-3fcr). Narrower than rejecting all job tokens: a script running
|
|
/// as an unprivileged identity has nothing to launder and still mints freely.
|
|
pub async fn forbid_elevated_job_token(
|
|
db: &DB,
|
|
email: &str,
|
|
job_id: Option<uuid::Uuid>,
|
|
) -> error::Result<()> {
|
|
if job_id.is_some() && is_devops_email(db, email).await? {
|
|
return Err(Error::NotAuthorized(
|
|
"A job token ($WM_TOKEN) running as a superadmin or devops user cannot mint a new \
|
|
token, which would carry that identity without the job provenance that caps it. \
|
|
If a script genuinely needs this, create a dedicated token from the User settings \
|
|
drawer (the 'Tokens' section), store it as a secret, and use that token explicitly \
|
|
instead of $WM_TOKEN."
|
|
.to_owned(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Forbid an irreversible action against the *account* a job token runs as.
|
|
///
|
|
/// A job token borrows an `on_behalf_of` identity to do the runnable's work, and a
|
|
/// `wm_deployers` member may point that at any real user. Destroying the account or
|
|
/// its credentials is never that work, and unlike the privilege gates the damage
|
|
/// does not depend on the identity being elevated — so this rejects every job
|
|
/// token, not just superadmin/devops ones (GHSA-hfh4-cx4h-3fcr).
|
|
pub fn forbid_job_token_account_destruction(authed: &ApiAuthed) -> error::Result<()> {
|
|
if authed.job_id.is_some() {
|
|
return Err(Error::NotAuthorized(
|
|
"This endpoint cannot be called with a job token ($WM_TOKEN): it would destroy the \
|
|
account or credentials of the identity the job runs as. If this is genuinely \
|
|
intended, do it from the User settings drawer, or with a dedicated token created \
|
|
there and used explicitly instead of $WM_TOKEN."
|
|
.to_owned(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn check_scopes<F>(authed: &ApiAuthed, required: F) -> error::Result<()>
|
|
where
|
|
F: FnOnce() -> String,
|
|
{
|
|
if let Some(scopes) = authed.scopes.as_ref() {
|
|
let mut is_scoped_token = false;
|
|
let required_scope = ScopeDefinition::from_scope_string(&required())?;
|
|
for scope in scopes {
|
|
if !scope.starts_with("if_jobs:filter_tags:") {
|
|
if !is_scoped_token {
|
|
is_scoped_token = true;
|
|
}
|
|
|
|
match ScopeDefinition::from_scope_string(scope) {
|
|
Ok(scope) if scope.includes(&required_scope) => return Ok(()),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if is_scoped_token {
|
|
return Err(Error::PermissionDenied(format!(
|
|
"Required scope: {}",
|
|
required_scope.as_string()
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns the caller's "real" scope restrictions: every scope other than
|
|
/// `if_jobs:filter_tags:` tag filters. `None` means the token is unscoped and
|
|
/// has the full privileges of its user; `Some` means it is restricted to the
|
|
/// returned scopes. An empty or filter-tags-only scope list is treated as
|
|
/// unscoped, mirroring `check_scopes`/`check_route_access`.
|
|
fn scope_restrictions(scopes: Option<&[String]>) -> Option<Vec<&String>> {
|
|
let restrictions: Vec<&String> = scopes?
|
|
.iter()
|
|
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
|
|
.collect();
|
|
(!restrictions.is_empty()).then_some(restrictions)
|
|
}
|
|
|
|
/// True when the token carries no real scope restriction — unscoped, an empty scope
|
|
/// list, or only `if_jobs:filter_tags:` filters — so it holds the full privileges of
|
|
/// its user and can reach any non-job route they are authorized for (mirrors
|
|
/// `check_scopes` / `check_route_access`). A `false` result means the token is
|
|
/// genuinely scope-restricted.
|
|
pub fn is_effectively_unscoped(scopes: Option<&[String]>) -> bool {
|
|
scope_restrictions(scopes).is_none()
|
|
}
|
|
|
|
/// Forbid reaching the workspace encryption key with a scope-restricted token.
|
|
///
|
|
/// The key is not the read or the write of any one domain: it decrypts every secret
|
|
/// variable offline, outliving the token that reached it, and it mints the secrets
|
|
/// that unlock public apps. Replacing it is the same capability — the server
|
|
/// re-encrypts every secret under the new key, so a caller that chooses the key can
|
|
/// decrypt them all. No scope grants either, so any scope-restricted token is
|
|
/// refused. An admin check is not a substitute — it answers for the user behind the
|
|
/// token, not for the token's own scopes.
|
|
///
|
|
/// This bounds the token making the request, not every route to the key. A job token
|
|
/// is minted unscoped from its owner's privileges, so a `jobs:run` token still reaches
|
|
/// the key indirectly by running a job as a workspace admin — the same property that
|
|
/// lets git-sync export it. Confining that means not inheriting unscoped privilege
|
|
/// into job tokens, which is a far wider change than this guard.
|
|
pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<()> {
|
|
if is_effectively_unscoped(authed.scopes.as_deref()) {
|
|
return Ok(());
|
|
}
|
|
Err(Error::PermissionDenied(
|
|
"The workspace encryption key cannot be read or replaced with a scoped token: it \
|
|
decrypts every secret of the workspace offline, past the scopes and the lifetime of \
|
|
the token that reached it. Use a token created without scopes."
|
|
.to_string(),
|
|
))
|
|
}
|
|
|
|
/// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes
|
|
/// a credential on behalf of `authed`: the resulting credential must never be
|
|
/// more privileged than the caller's own token.
|
|
///
|
|
/// - An unscoped caller may grant any scopes (this is the existing UI/CLI flow).
|
|
/// - A scope-restricted caller may only grant scopes that are a subset of its
|
|
/// own, and may never produce an unscoped credential.
|
|
///
|
|
/// Without this, a `users:write` token could create or rescope a token to be
|
|
/// unscoped, and a `users:read` token could refresh into an unscoped session —
|
|
/// escaping its own restrictions.
|
|
pub fn ensure_scopes_within_caller(
|
|
authed: &ApiAuthed,
|
|
requested_scopes: Option<&[String]>,
|
|
) -> error::Result<()> {
|
|
if let Some(caller_restrictions) = scope_restrictions(authed.scopes.as_deref()) {
|
|
let Some(requested_restrictions) = scope_restrictions(requested_scopes) else {
|
|
return Err(Error::PermissionDenied(
|
|
"A scope-restricted token cannot create or update a token with broader (unscoped) \
|
|
privileges"
|
|
.to_string(),
|
|
));
|
|
};
|
|
|
|
// MCP scopes (`mcp:all`, `mcp:favorites`, `mcp:scripts:*`, etc.) use a
|
|
// custom format that ScopeDefinition::from_scope_string parses
|
|
// permissively but the MCP runtime interprets via its own parser
|
|
// (parse_mcp_scopes). The two views disagree — e.g. the generic parser
|
|
// accepts `mcp:scripts` as an unrestricted-resource scope, while the
|
|
// MCP runtime ignores it as unrecognized but interprets `mcp:scripts:*`
|
|
// as granting all scripts. So generic containment would silently allow
|
|
// `mcp:scripts` → `mcp:scripts:*` (a widening). Legitimate MCP token
|
|
// issuance goes through the OAuth gateway (mcp/oauth_server.rs), not
|
|
// these user-token endpoints, so require byte-identical match for MCP
|
|
// scopes here rather than trying to mirror MCP semantics in two places.
|
|
// Unparseable non-MCP caller scopes are intentionally dropped
|
|
// (fail-closed): a caller scope that fails to parse can only narrow
|
|
// the set of requested scopes that get covered, never widen it.
|
|
// Unparseable requested scopes surface as `BadRequest`, which is what
|
|
// we want — the client is sending garbage.
|
|
let parsed_caller: Vec<ScopeDefinition> = caller_restrictions
|
|
.iter()
|
|
.filter(|s| !s.starts_with("mcp:"))
|
|
.filter_map(|s| ScopeDefinition::from_scope_string(s).ok())
|
|
.collect();
|
|
let caller_mcp: std::collections::HashSet<&str> = caller_restrictions
|
|
.iter()
|
|
.filter(|s| s.starts_with("mcp:"))
|
|
.map(|s| s.as_str())
|
|
.collect();
|
|
|
|
for requested in requested_restrictions {
|
|
if requested.starts_with("mcp:") {
|
|
if !caller_mcp.contains(requested.as_str()) {
|
|
return Err(Error::PermissionDenied(format!(
|
|
"A scope-restricted token cannot grant MCP scope '{requested}' unless the \
|
|
caller holds the same scope verbatim"
|
|
)));
|
|
}
|
|
continue;
|
|
}
|
|
let requested_scope = ScopeDefinition::from_scope_string(requested)?;
|
|
let covered = parsed_caller
|
|
.iter()
|
|
.any(|caller_scope| scope_contains(caller_scope, &requested_scope));
|
|
if !covered {
|
|
return Err(Error::PermissionDenied(format!(
|
|
"A scope-restricted token cannot grant scope '{requested}' which exceeds its \
|
|
own scopes"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// `if_jobs:filter_tags:` fences which job tags a token can run on (enforced
|
|
// at job operations as `v2_job.tag = ANY(...)`), and is checked independently
|
|
// of domain/action/resource subset. A caller restricted by filter_tags must
|
|
// not be able to mint or rescope a credential that drops or widens the fence
|
|
// — even if the caller has no other scope restrictions (filter_tags-only
|
|
// tokens otherwise look "unscoped" to `scope_restrictions`).
|
|
if let Some(caller_tags) = first_filter_tags(authed.scopes.as_deref()) {
|
|
let Some(requested_tags) = first_filter_tags(requested_scopes) else {
|
|
return Err(Error::PermissionDenied(
|
|
"A token restricted by if_jobs:filter_tags cannot mint or rescope a token that \
|
|
drops the tag restriction"
|
|
.to_string(),
|
|
));
|
|
};
|
|
let caller_set: std::collections::HashSet<&str> = caller_tags.iter().copied().collect();
|
|
for tag in &requested_tags {
|
|
if !caller_set.contains(tag) {
|
|
return Err(Error::PermissionDenied(format!(
|
|
"A token restricted by if_jobs:filter_tags cannot grant tag '{tag}' which is \
|
|
not within its own filter_tags"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Tags from the first `if_jobs:filter_tags:<a,b,...>` scope, matching the
|
|
/// semantics of [`get_scope_tags`] (which is what the job runtime consults).
|
|
/// Returns `None` if no such scope is present.
|
|
fn first_filter_tags(scopes: Option<&[String]>) -> Option<Vec<&str>> {
|
|
scopes?.iter().find_map(|s| {
|
|
s.strip_prefix("if_jobs:filter_tags:")
|
|
.map(|tags| tags.split(',').collect())
|
|
})
|
|
}
|
|
|
|
/// Whether `caller` grants at least everything `requested` grants (directional
|
|
/// containment).
|
|
///
|
|
/// This is intentionally NOT `ScopeDefinition::includes`: that method answers
|
|
/// "does this scope grant access to a required action" using OR semantics over
|
|
/// resources (any overlap counts, and a `*` on either side matches), which is
|
|
/// correct for access checks but unsafe for subset checks — it would let a
|
|
/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or
|
|
/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that
|
|
/// EVERY requested resource is covered by SOME caller resource.
|
|
fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool {
|
|
if caller.domain != requested.domain {
|
|
return false;
|
|
}
|
|
|
|
// write subsumes read; otherwise the action must match exactly.
|
|
match (caller.action.as_str(), requested.action.as_str()) {
|
|
(c, r) if c == r || (c == "write" && r == "read") => {}
|
|
// Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an
|
|
// app-editor token can mint the narrower run-only credential.
|
|
("write", "run") if caller.domain == "apps" => {}
|
|
_ => return false,
|
|
}
|
|
|
|
if caller.domain == "jobs" && caller.action == "run" {
|
|
match (&caller.kind, &requested.kind) {
|
|
(Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => {
|
|
return false
|
|
}
|
|
// Caller pinned to a kind, but the request covers any kind.
|
|
(Some(_), None) => return false,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
match (&caller.resource, &requested.resource) {
|
|
// Caller is unrestricted on resources: covers everything.
|
|
(None, _) => true,
|
|
// Caller is resource-restricted but the request is not: broader, unless the
|
|
// caller lists `*` and so already spans every path. Kept in step with
|
|
// `ScopeDefinition::includes`, which accepts that same grant for a
|
|
// whole-collection read: what a token may exercise, it may also delegate.
|
|
(Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"),
|
|
(Some(caller_resources), Some(requested_resources)) => {
|
|
resource_set_contains(caller_resources, requested_resources)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every resource in `requested` must be covered by some resource in `caller`.
|
|
fn resource_set_contains(caller: &[String], requested: &[String]) -> bool {
|
|
if caller.iter().any(|r| r == "*") {
|
|
return true;
|
|
}
|
|
requested
|
|
.iter()
|
|
.all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req)))
|
|
}
|
|
|
|
/// Directional: does the single caller resource pattern cover `requested`?
|
|
/// `caller` may be an exact path or a `<prefix>/*` subtree wildcard; `requested`
|
|
/// may itself be a subtree wildcard, in which case the whole requested subtree
|
|
/// must fall within the caller's subtree.
|
|
fn resource_covers(caller: &str, requested: &str) -> bool {
|
|
if caller == requested {
|
|
return true;
|
|
}
|
|
let Some(prefix) = caller.strip_suffix("/*") else {
|
|
// An exact caller resource only covers itself (handled above).
|
|
return false;
|
|
};
|
|
let requested_base = requested.strip_suffix("/*").unwrap_or(requested);
|
|
requested_base == prefix
|
|
|| (requested_base.starts_with(prefix)
|
|
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
|
|
}
|
|
|
|
/// Returns a predicate that checks whether `path` is within the token's
|
|
/// scope for `{domain}:{action}:{path}`. For tokens without scope
|
|
/// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes),
|
|
/// the predicate always returns `true`.
|
|
///
|
|
/// Pre-parses the token's scopes once so the returned closure can cheaply
|
|
/// filter large listings without re-parsing on each call.
|
|
pub fn build_scope_path_predicate(
|
|
authed: &ApiAuthed,
|
|
domain: &str,
|
|
action: &str,
|
|
) -> impl Fn(&str) -> bool {
|
|
// Mirror check_scopes semantics: a token is "scope-restricted" iff it has
|
|
// at least one non-`if_jobs:filter_tags:` scope. Unparseable scopes still
|
|
// count as restrictive — they just match nothing.
|
|
let (is_scoped_token, parsed): (bool, Vec<ScopeDefinition>) = match authed.scopes.as_ref() {
|
|
Some(scopes) => {
|
|
let mut is_scoped = false;
|
|
let parsed = scopes
|
|
.iter()
|
|
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
|
|
.inspect(|_| is_scoped = true)
|
|
.filter_map(|s| ScopeDefinition::from_scope_string(s).ok())
|
|
.collect();
|
|
(is_scoped, parsed)
|
|
}
|
|
None => (false, Vec::new()),
|
|
};
|
|
let domain = domain.to_string();
|
|
let action = action.to_string();
|
|
|
|
move |path: &str| -> bool {
|
|
if !is_scoped_token {
|
|
return true;
|
|
}
|
|
let required =
|
|
match ScopeDefinition::from_scope_string(&format!("{}:{}:{}", domain, action, path)) {
|
|
Ok(r) => r,
|
|
Err(_) => return false,
|
|
};
|
|
parsed.iter().any(|s| s.includes(&required))
|
|
}
|
|
}
|
|
|
|
/// The same `domain:action` path grant as [`build_scope_path_predicate`], decomposed
|
|
/// into what a SQL `WHERE` clause needs so the filtering happens IN the query.
|
|
///
|
|
/// Filtering in SQL rather than dropping rows after the fetch is mandatory wherever
|
|
/// the result is paginated: a post-fetch filter makes a page's size — and any
|
|
/// continuation cursor derived from it — reveal the count of rows the caller cannot
|
|
/// see. `ScopePathFilter::allows` mirrors the emitted SQL, and a cross-check test
|
|
/// pins both to the predicate.
|
|
pub enum ScopePathFilter {
|
|
/// Unscoped token, or a grant that covers every path: no restriction.
|
|
AllowAll,
|
|
/// A path is granted iff it equals an `exact` entry or sits at or under a
|
|
/// `prefix` (from a `prefix/*` grant, matched on the `/` boundary). Both empty
|
|
/// grants nothing.
|
|
Restricted { exact: Vec<String>, prefix: Vec<String> },
|
|
}
|
|
|
|
impl ScopePathFilter {
|
|
/// Whether `path` is granted. Mirrors the SQL a caller builds from this filter,
|
|
/// and the matching rule in `resource_matches_pattern`.
|
|
pub fn allows(&self, path: &str) -> bool {
|
|
match self {
|
|
ScopePathFilter::AllowAll => true,
|
|
ScopePathFilter::Restricted { exact, prefix } => {
|
|
exact.iter().any(|e| e == path)
|
|
|| prefix.iter().any(|p| {
|
|
path == p || path.strip_prefix(p).is_some_and(|r| r.starts_with('/'))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restrictions equivalent to `build_scope_path_predicate(authed, domain, action)`,
|
|
/// but pushable into SQL. See [`ScopePathFilter`]. Not for `jobs:run` scopes (whose
|
|
/// `kind` dimension this ignores), matching the predicate's path-domain use.
|
|
pub fn build_scope_path_filter(authed: &ApiAuthed, domain: &str, action: &str) -> ScopePathFilter {
|
|
let (is_scoped_token, parsed): (bool, Vec<ScopeDefinition>) = match authed.scopes.as_ref() {
|
|
Some(scopes) => {
|
|
let mut is_scoped = false;
|
|
let parsed = scopes
|
|
.iter()
|
|
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
|
|
.inspect(|_| is_scoped = true)
|
|
.filter_map(|s| ScopeDefinition::from_scope_string(s).ok())
|
|
.collect();
|
|
(is_scoped, parsed)
|
|
}
|
|
None => (false, Vec::new()),
|
|
};
|
|
if !is_scoped_token {
|
|
return ScopePathFilter::AllowAll;
|
|
}
|
|
let mut exact = Vec::new();
|
|
let mut prefix = Vec::new();
|
|
for s in &parsed {
|
|
if s.domain != domain {
|
|
continue;
|
|
}
|
|
// `write` covers `read`, mirroring ScopeDefinition::includes' action rule.
|
|
if !(s.action == action || (s.action == "write" && action == "read")) {
|
|
continue;
|
|
}
|
|
match &s.resource {
|
|
// A domain:action scope with no path part grants every path.
|
|
None => return ScopePathFilter::AllowAll,
|
|
Some(resources) => {
|
|
for r in resources {
|
|
// `*` grants every path (resources_match's wildcard short-circuit).
|
|
if r == "*" {
|
|
return ScopePathFilter::AllowAll;
|
|
}
|
|
match r.strip_suffix("/*") {
|
|
Some(p) => prefix.push(p.to_string()),
|
|
None => exact.push(r.clone()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ScopePathFilter::Restricted { exact, prefix }
|
|
}
|
|
|
|
/// Assert the caller holds the instance-level `devops` role under their own
|
|
/// credentials.
|
|
///
|
|
/// `devops` is instance-level and [`is_devops_email`] is also true for
|
|
/// superadmins, so this gate is reachable by the same job token that
|
|
/// [`require_super_admin`] rejects, and is capped the same way
|
|
/// (GHSA-hfh4-cx4h-3fcr).
|
|
pub async fn require_devops_role(db: &DB, authed: &ApiAuthed) -> error::Result<()> {
|
|
if authed.job_id.is_some() {
|
|
return Err(Error::NotAuthorized(
|
|
"This endpoint cannot be called with a job token ($WM_TOKEN). If a script \
|
|
genuinely needs this, create a dedicated token from the User settings drawer \
|
|
(the 'Tokens' section), store it as a secret, and use that token explicitly \
|
|
instead of $WM_TOKEN."
|
|
.to_owned(),
|
|
));
|
|
}
|
|
if is_devops_email(db, &authed.email).await? {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::NotAuthorized(
|
|
"This endpoint requires the caller to have the `devops` role".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
// ------------ Folder ownership checks ------------
|
|
|
|
pub fn is_owner(ApiAuthed { is_admin, folders, .. }: &ApiAuthed, name: &str) -> bool {
|
|
if *is_admin {
|
|
true
|
|
} else {
|
|
folders.into_iter().any(|x| x.0 == name && x.2)
|
|
}
|
|
}
|
|
|
|
pub fn require_is_owner(authed: &ApiAuthed, name: &str) -> Result<()> {
|
|
if is_owner(authed, name) {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::NotAuthorized(format!(
|
|
"You are not owner of the folder {}",
|
|
name
|
|
)))
|
|
}
|
|
}
|
|
|
|
pub fn require_owner_of_path(authed: &ApiAuthed, path: &str) -> Result<()> {
|
|
if authed.is_admin {
|
|
return Ok(());
|
|
}
|
|
if !path.is_empty() {
|
|
let splitted = path.split("/").collect::<Vec<&str>>();
|
|
// A valid path is at least `<kind>/<name>` (e.g. `u/alice/...`,
|
|
// `f/folder/...`). Guard the `splitted[1]` accesses below so a
|
|
// malformed single-segment path returns a clear error instead of
|
|
// panicking with an out-of-bounds index.
|
|
if splitted.len() < 2 {
|
|
return Err(Error::BadRequest(format!(
|
|
"Invalid path '{}': a valid path starts with 'u/<user>/' or 'f/<folder>/'",
|
|
path
|
|
)));
|
|
}
|
|
if splitted[0] == "u" {
|
|
if splitted[1] == authed.username {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::BadRequest(format!(
|
|
"only the owner {} is authorized to perform this operation",
|
|
splitted[1]
|
|
)))
|
|
}
|
|
} else if splitted[0] == "f" {
|
|
require_is_owner(authed, splitted[1])
|
|
} else {
|
|
Err(Error::BadRequest(format!(
|
|
"Not recognized path kind: {}",
|
|
path
|
|
)))
|
|
}
|
|
} else {
|
|
Err(Error::BadRequest(
|
|
"Cannot be owner of an empty path".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
// ------------ Scope tag helpers ------------
|
|
|
|
pub fn get_scope_tags(authed: &ApiAuthed) -> Option<Vec<&str>> {
|
|
authed.scopes.as_ref()?.iter().find_map(|s| {
|
|
if s.starts_with("if_jobs:filter_tags:") {
|
|
Some(
|
|
s.trim_start_matches("if_jobs:filter_tags:")
|
|
.split(",")
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
// ------------ Maybe refresh folders ------------
|
|
|
|
pub async fn maybe_refresh_folders(
|
|
path: &str,
|
|
w_id: &str,
|
|
authed: ApiAuthed,
|
|
db: &DB,
|
|
) -> ApiAuthed {
|
|
use windmill_common::auth::{get_folders_for_user, get_groups_for_user};
|
|
|
|
if authed.is_admin {
|
|
return authed;
|
|
}
|
|
let splitted = path.split('/').collect::<Vec<_>>();
|
|
if splitted.len() >= 2
|
|
&& splitted[0] == "f"
|
|
&& !authed.folders.iter().any(|(f, _, _)| f == splitted[1])
|
|
{
|
|
let name = &authed.username;
|
|
let groups = get_groups_for_user(w_id, name, &authed.email, db)
|
|
.await
|
|
.ok()
|
|
.unwrap_or_default();
|
|
|
|
let folders = get_folders_for_user(w_id, name, &groups, db)
|
|
.await
|
|
.ok()
|
|
.unwrap_or_default();
|
|
ApiAuthed { folders, ..authed }
|
|
} else {
|
|
authed
|
|
}
|
|
}
|
|
|
|
// ------------ FromRequestParts impls (direct call to auth module) ------------
|
|
|
|
impl<S> FromRequestParts<S> for ApiAuthed
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = Error;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
_state: &S,
|
|
) -> std::result::Result<Self, Self::Rejection> {
|
|
let opt_job_authed = OptJobAuthed::from_request_parts(parts, _state).await?;
|
|
Ok(opt_job_authed.authed)
|
|
}
|
|
}
|
|
|
|
impl<S> OptionalFromRequestParts<S> for ApiAuthed
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = std::convert::Infallible;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &S,
|
|
) -> std::result::Result<Option<Self>, Self::Rejection> {
|
|
Ok(
|
|
<Self as FromRequestParts<S>>::from_request_parts(parts, state)
|
|
.await
|
|
.ok(),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl<S> FromRequestParts<S> for OptJobAuthed
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = Error;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
_state: &S,
|
|
) -> std::result::Result<Self, Self::Rejection> {
|
|
// Swap out parts so we can pass ownership to resolve_opt_job_authed
|
|
let owned_parts = std::mem::replace(parts, empty_parts());
|
|
match auth::resolve_opt_job_authed(owned_parts).await {
|
|
Ok((result, returned_parts)) => {
|
|
*parts = returned_parts;
|
|
Ok(result)
|
|
}
|
|
Err((err, returned_parts)) => {
|
|
*parts = returned_parts;
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn empty_parts() -> Parts {
|
|
let (parts, _body) = http::Request::new(()).into_parts();
|
|
parts
|
|
}
|
|
|
|
// ------------ OptAuthed (optional auth extractor) ------------
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct OptAuthed(pub Option<ApiAuthed>);
|
|
|
|
impl<S> FromRequestParts<S> for OptAuthed
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = (http::StatusCode, String);
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &S,
|
|
) -> std::result::Result<Self, Self::Rejection> {
|
|
<ApiAuthed as FromRequestParts<S>>::from_request_parts(parts, state)
|
|
.await
|
|
.map(|authed| Self(Some(authed)))
|
|
.or_else(|_| Ok(Self(None)))
|
|
}
|
|
}
|
|
|
|
// ------------ fetch_api_authed helpers ------------
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref API_AUTHED_CACHE: quick_cache::sync::Cache<(String,String,String), ExpiringAuthCache> = quick_cache::sync::Cache::new(300);
|
|
}
|
|
|
|
#[allow(unused)]
|
|
pub async fn fetch_api_authed(
|
|
username: String,
|
|
email: String,
|
|
w_id: &str,
|
|
db: &DB,
|
|
username_override: Option<String>,
|
|
) -> error::Result<ApiAuthed> {
|
|
let permissioned_as = username_to_permissioned_as(username.as_str());
|
|
fetch_api_authed_from_permissioned_as(permissioned_as, email, w_id, db, username_override).await
|
|
}
|
|
|
|
#[allow(unused)]
|
|
pub async fn fetch_api_authed_from_permissioned_as(
|
|
permissioned_as: String,
|
|
email: String,
|
|
w_id: &str,
|
|
db: &DB,
|
|
username_override: Option<String>,
|
|
) -> error::Result<ApiAuthed> {
|
|
// Keyed by the supplied address, so an entry built for a principal's previous holder is reused
|
|
// while that address is still supplied, until its 120s expiry: a cached dispatch address is
|
|
// evicted sooner, an app's stored one (a username deleted then reused) may not be. Accepted;
|
|
// the rebuild after expiry is the current holder's.
|
|
let key = (w_id.to_string(), permissioned_as.clone(), email.clone());
|
|
|
|
let mut api_authed = match API_AUTHED_CACHE.get(&key) {
|
|
Some(expiring_authed) if expiring_authed.expiry > chrono::Utc::now() => {
|
|
tracing::debug!("API authed cache hit for user {}", email);
|
|
expiring_authed.authed
|
|
}
|
|
_ => {
|
|
tracing::debug!("API authed cache miss for user {}", email);
|
|
|
|
let authed =
|
|
fetch_authed_from_permissioned_as(&permissioned_as, &email, w_id, db).await?;
|
|
|
|
let api_authed = ApiAuthed {
|
|
username: authed.username,
|
|
// The resolved one, not the address we were handed: that is the point of
|
|
// `fetch_authed_from_permissioned_as` validating it against the principal's live
|
|
// binding, and this value goes on to the job row, `job_perms` and the JWT.
|
|
email: authed.email,
|
|
is_admin: authed.is_admin,
|
|
is_operator: authed.is_operator,
|
|
groups: authed.groups,
|
|
folders: authed.folders,
|
|
scopes: authed.scopes,
|
|
username_override: None,
|
|
username_override_is_token_label: false,
|
|
is_session_token: false,
|
|
token_prefix: authed.token_prefix,
|
|
read_only: false,
|
|
job_id: None,
|
|
credential_expiry: None,
|
|
};
|
|
|
|
API_AUTHED_CACHE.insert(
|
|
key,
|
|
ExpiringAuthCache {
|
|
authed: api_authed.clone(),
|
|
expiry: chrono::Utc::now() + chrono::Duration::try_seconds(120).unwrap(),
|
|
job_id: None,
|
|
},
|
|
);
|
|
|
|
api_authed
|
|
}
|
|
};
|
|
|
|
// Callers pass a trigger or app identity here, never a token label.
|
|
api_authed.set_acting_username_override(username_override);
|
|
Ok(api_authed)
|
|
}
|
|
|
|
// ------------ Token creation ------------
|
|
|
|
#[derive(serde::Deserialize)]
|
|
pub struct NewToken {
|
|
pub label: Option<String>,
|
|
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
|
|
pub impersonate_email: Option<String>,
|
|
pub scopes: Option<Vec<String>>,
|
|
pub workspace_id: Option<String>,
|
|
#[serde(default)]
|
|
pub read_only: Option<bool>,
|
|
}
|
|
|
|
impl NewToken {
|
|
pub fn new(
|
|
label: Option<String>,
|
|
expiration: Option<chrono::DateTime<chrono::Utc>>,
|
|
impersonate_email: Option<String>,
|
|
scopes: Option<Vec<String>>,
|
|
workspace_id: Option<String>,
|
|
read_only: Option<bool>,
|
|
) -> Self {
|
|
Self { label, expiration, impersonate_email, scopes, workspace_id, read_only }
|
|
}
|
|
}
|
|
|
|
/// Low-level token mint shared by trusted callers (the user-facing
|
|
/// `tokens/create` handler and internal mints such as native-trigger webhook
|
|
/// tokens). It does NOT enforce that `token_config.scopes` is within the
|
|
/// caller's own scopes — callers exposed to untrusted input must call
|
|
/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally
|
|
/// skip it, since their scopes derive from the action being authorized, not the
|
|
/// caller's token).
|
|
///
|
|
/// A token the system mints for itself with an `expiration` needs a label reserved in
|
|
/// `windmill_common::auth::is_user_token`, or its expiry alerts its owner (docs/auth-surface.md).
|
|
pub async fn create_token_internal(
|
|
tx: &mut sqlx::PgConnection,
|
|
db: &DB,
|
|
authed: &ApiAuthed,
|
|
token_config: NewToken,
|
|
) -> Result<String> {
|
|
use tracing::Instrument;
|
|
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
|
use windmill_common::{
|
|
min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH, utils::rd_string, worker::CLOUD_HOSTED,
|
|
};
|
|
|
|
let token = rd_string(32);
|
|
let t_hash = hash_token(&token);
|
|
let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token);
|
|
|
|
// Write plaintext token column until all workers support hash-based lookup
|
|
let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
|
|
None
|
|
} else {
|
|
Some(&token)
|
|
};
|
|
|
|
let is_super_admin = sqlx::query_scalar!(
|
|
"SELECT super_admin FROM password WHERE email = $1",
|
|
authed.email
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?
|
|
.unwrap_or(false);
|
|
if *CLOUD_HOSTED {
|
|
let nb_tokens =
|
|
sqlx::query_scalar!("SELECT COUNT(*) FROM token WHERE email = $1", &authed.email)
|
|
.fetch_one(db)
|
|
.await?;
|
|
if nb_tokens.unwrap_or(0) >= 10000 {
|
|
return Err(Error::BadRequest(
|
|
"You have reached the maximum number of tokens (10000) on cloud. Contact support@windmill.dev to increase the limit"
|
|
.to_string(),
|
|
));
|
|
}
|
|
}
|
|
let rows = sqlx::query!(
|
|
"INSERT INTO token
|
|
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only)
|
|
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
WHERE $9::varchar IS NULL OR NOT EXISTS(
|
|
SELECT 1 FROM workspace WHERE id = $9 AND deleted = true
|
|
)",
|
|
t_hash,
|
|
t_prefix,
|
|
plaintext as Option<&str>,
|
|
authed.email,
|
|
token_config.label,
|
|
token_config.expiration,
|
|
is_super_admin,
|
|
token_config.scopes.as_ref().map(|x| x.as_slice()),
|
|
token_config.workspace_id,
|
|
token_config.read_only.unwrap_or(false),
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if rows.rows_affected() == 0 {
|
|
return Err(Error::BadRequest(
|
|
"Cannot create a token for an archived workspace".to_string(),
|
|
));
|
|
}
|
|
|
|
register_token_expiry_notification(
|
|
&mut *tx,
|
|
&t_hash,
|
|
token_config.label.as_deref(),
|
|
token_config.expiration,
|
|
)
|
|
.await;
|
|
|
|
audit_log(
|
|
&mut *tx,
|
|
authed,
|
|
"users.token.create",
|
|
ActionKind::Create,
|
|
&"global",
|
|
Some(t_prefix),
|
|
None,
|
|
)
|
|
.instrument(tracing::info_span!("token", email = &authed.email))
|
|
.await?;
|
|
|
|
Ok(token)
|
|
}
|
|
|
|
/// Insert a pending expiry notification row for user tokens that have an expiration.
|
|
/// Stores the token_hash so the join in check_expiring_tokens works even when
|
|
/// the plaintext token column is NULL (after hash migration).
|
|
pub async fn register_token_expiry_notification(
|
|
tx: &mut sqlx::PgConnection,
|
|
token_hash: &str,
|
|
label: Option<&str>,
|
|
expiration: Option<chrono::DateTime<chrono::Utc>>,
|
|
) {
|
|
let Some(expiration) = expiration else { return };
|
|
// System tokens don't get expiry notifications.
|
|
if !windmill_common::auth::is_user_token(label) {
|
|
return;
|
|
}
|
|
let warning_days = windmill_common::auth::TOKEN_EXPIRY_WARNING_DAYS;
|
|
if expiration <= chrono::Utc::now() + chrono::Duration::days(warning_days.into()) {
|
|
return;
|
|
}
|
|
if let Err(e) = sqlx::query!(
|
|
"INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
|
token_hash,
|
|
expiration,
|
|
)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
tracing::error!("Failed to register token expiry notification: {}", e);
|
|
}
|
|
}
|
|
|
|
// ------------ Permission helpers ------------
|
|
|
|
pub fn get_perm_in_extra_perms_for_authed(
|
|
v: serde_json::Value,
|
|
authed: &ApiAuthed,
|
|
) -> Option<bool> {
|
|
match v {
|
|
serde_json::Value::Object(obj) => {
|
|
let mut keys = vec![format!("u/{}", authed.username)];
|
|
for g in authed.groups.iter() {
|
|
keys.push(format!("g/{}", g));
|
|
}
|
|
let mut res = None;
|
|
for k in keys {
|
|
if let Some(v) = obj.get(&k) {
|
|
if let Some(v) = v.as_bool() {
|
|
if v {
|
|
return Some(true);
|
|
}
|
|
res = Some(v);
|
|
}
|
|
}
|
|
}
|
|
res
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub async fn require_is_writer(
|
|
authed: &ApiAuthed,
|
|
path: &str,
|
|
w_id: &str,
|
|
db: DB,
|
|
query: &str,
|
|
kind: &str,
|
|
) -> Result<()> {
|
|
if authed.is_admin {
|
|
return Ok(());
|
|
}
|
|
if !path.is_empty() {
|
|
if require_owner_of_path(authed, path).is_ok() {
|
|
return Ok(());
|
|
}
|
|
if path.starts_with("f/") && path.split('/').count() >= 2 {
|
|
let folder = path.split('/').nth(1).unwrap();
|
|
let extra_perms = sqlx::query_scalar!(
|
|
"SELECT extra_perms FROM folder WHERE name = $1 AND workspace_id = $2",
|
|
folder,
|
|
w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
if let Some(perms) = extra_perms {
|
|
let is_folder_writer =
|
|
get_perm_in_extra_perms_for_authed(perms, authed).unwrap_or(false);
|
|
if is_folder_writer {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
let extra_perms = sqlx::query_scalar(query)
|
|
.bind(path)
|
|
.bind(w_id)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
if let Some(perms) = extra_perms {
|
|
let perm = get_perm_in_extra_perms_for_authed(perms, authed);
|
|
match perm {
|
|
Some(true) => Ok(()),
|
|
Some(false) => Err(Error::BadRequest(format!(
|
|
"User {} is not a writer of {kind} path {path}",
|
|
authed.username
|
|
))),
|
|
None => Err(Error::BadRequest(format!(
|
|
"User {} has neither read or write permission on {kind} {path}",
|
|
authed.username
|
|
))),
|
|
}
|
|
} else {
|
|
Err(Error::BadRequest(format!(
|
|
"{path} does not exist yet and user {} is not an owner of the parent folder",
|
|
authed.username
|
|
)))
|
|
}
|
|
} else {
|
|
Err(Error::BadRequest(format!(
|
|
"Cannot be writer of an empty path"
|
|
)))
|
|
}
|
|
}
|
|
|
|
// ------------ Preview access check ------------
|
|
|
|
pub fn require_path_read_access_for_preview(
|
|
authed: &ApiAuthed,
|
|
path: &Option<String>,
|
|
) -> Result<()> {
|
|
let Some(path) = path else {
|
|
return Ok(());
|
|
};
|
|
|
|
// Reject path traversal before any privilege-based short-circuit. A Preview's
|
|
// path is request-supplied and bypasses the DB `proper_id` CHECK that deployed
|
|
// runnables get; it then flows to the worker where it builds on-disk module
|
|
// directories. A `..` segment or an absolute path could let a write escape the
|
|
// per-job dir.
|
|
if path.starts_with('/') || path.split('/').any(|seg| seg == "..") || path.contains('\0') {
|
|
return Err(Error::BadRequest(format!(
|
|
"Invalid path for preview job: {}",
|
|
path
|
|
)));
|
|
}
|
|
|
|
if authed.is_admin {
|
|
return Ok(());
|
|
}
|
|
|
|
if path.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let splitted: Vec<&str> = path.split('/').collect();
|
|
if splitted.len() < 2 {
|
|
return Err(Error::BadRequest(format!(
|
|
"Invalid path format for preview job: {}",
|
|
path
|
|
)));
|
|
}
|
|
|
|
match splitted[0] {
|
|
"u" => {
|
|
if splitted[1] == authed.username {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::BadRequest(format!(
|
|
"You can only run preview jobs in your own namespace (u/{}) or in folders you have read access to",
|
|
authed.username
|
|
)))
|
|
}
|
|
}
|
|
"f" => {
|
|
let folder = splitted[1];
|
|
if authed.folders.iter().any(|(f, _, _)| f == folder) {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::BadRequest(format!(
|
|
"You do not have read access to folder '{}'. Preview jobs require at least read access to the target folder.",
|
|
folder
|
|
)))
|
|
}
|
|
}
|
|
"hub" => Ok(()),
|
|
_ => Err(Error::BadRequest(format!(
|
|
"Invalid path format for preview job: {}. Path must start with 'u/' or 'f/'",
|
|
path
|
|
))),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn authed_with_scopes(scopes: Option<Vec<&str>>) -> ApiAuthed {
|
|
ApiAuthed {
|
|
scopes: scopes.map(|v| v.into_iter().map(String::from).collect()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// `display_username` is what `push` credits a run to, so a token label standing in for
|
|
/// it erases the caller from `created_by` and from the audit trail — irrecoverably when
|
|
/// `permissioned_as` is an on-behalf-of identity that also takes the `username` slot.
|
|
#[test]
|
|
fn generic_token_label_credits_the_token_owner() {
|
|
let owner_of = |label: &str| {
|
|
let (username_override, username_override_is_token_label) =
|
|
auth::username_override_from_label(Some(label.to_string()));
|
|
ApiAuthed {
|
|
username: "alice".into(),
|
|
username_override,
|
|
username_override_is_token_label,
|
|
..Default::default()
|
|
}
|
|
};
|
|
|
|
// Arbitrary user-chosen labels, and the auto-generated MCP OAuth one.
|
|
assert_eq!(owner_of("my-personal-token").display_username(), "alice");
|
|
assert_eq!(
|
|
owner_of("mcp-oauth-mcp-client-9f3a1c").display_username(),
|
|
"alice"
|
|
);
|
|
|
|
// A trigger-*shaped* label is just as user-settable as any other, so it is credited
|
|
// the same way. Its value is still kept as the override, for `require_job_read_access`.
|
|
let webhookish = owner_of("webhook-f/svc/my_script");
|
|
assert_eq!(webhookish.display_username(), "alice");
|
|
assert_eq!(
|
|
webhookish.username_override.as_deref(),
|
|
Some("webhook-f/svc/my_script")
|
|
);
|
|
|
|
// Only labels `create_token` refuses to mint name the entity that fired the request.
|
|
assert_eq!(
|
|
owner_of("ephemeral-webhook-google-abc12").display_username(),
|
|
"ephemeral-webhook-google-abc12"
|
|
);
|
|
|
|
// Minted by the editor through the public handler, so it names no principal either.
|
|
assert_eq!(owner_of("Ephemeral lsp token").display_username(), "alice");
|
|
|
|
// The SMTP trigger sets its `email-*` identity server-side rather than through a
|
|
// label, so a token carrying that prefix is just a user token.
|
|
assert_eq!(owner_of("email-f/team/inbox").display_username(), "alice");
|
|
assert_eq!(
|
|
owner_of("ephemeral-script-end-user-enduser42").display_username(),
|
|
"enduser42"
|
|
);
|
|
|
|
// The end-user token forwards a `created_by` verbatim, and `created_by` is not
|
|
// constrained to a username — a job launched before the owner was credited still
|
|
// carries `label-*`. That is an end user, not this token's label, so it stands.
|
|
assert_eq!(
|
|
owner_of("ephemeral-script-end-user-label-alice").display_username(),
|
|
"label-alice"
|
|
);
|
|
}
|
|
|
|
/// A browser session is the one shape left unstamped, so the Runs page can say "a token
|
|
/// started this" without claiming the converse. Every other label — every shape a member
|
|
/// can pass to `create_token` — is `webhook`.
|
|
#[test]
|
|
fn only_a_browser_session_is_left_unstamped() {
|
|
let kind_of = |label: Option<&str>| {
|
|
ApiAuthed {
|
|
is_session_token: windmill_common::auth::is_session_label(label),
|
|
..Default::default()
|
|
}
|
|
.trigger_or_fallback(None)
|
|
.map(|t| t.trigger_kind.to_string())
|
|
};
|
|
|
|
assert_eq!(kind_of(Some("session")), None);
|
|
|
|
for label in [
|
|
Some("my-personal-token"),
|
|
Some("webhook-f/svc/my_script"),
|
|
Some("Ephemeral lsp token"),
|
|
Some("ephemeral-script"),
|
|
Some("ephemeral-webhook-google-abc12"),
|
|
Some("mcp-oauth-mcp-client-9f3a1c"),
|
|
Some(""),
|
|
// A label-less token: the job WM_TOKEN, and any token created without one.
|
|
None,
|
|
] {
|
|
assert_eq!(
|
|
kind_of(label).as_deref(),
|
|
Some("webhook"),
|
|
"label {label:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A trigger that built its own metadata must survive the fallback, or a scheduled or
|
|
/// routed run started under a personal token would be re-attributed to a webhook.
|
|
#[test]
|
|
fn a_real_trigger_wins_over_the_token_fallback() {
|
|
let authed = ApiAuthed::default();
|
|
let schedule = TriggerMetadata::new(
|
|
Some("u/alice/nightly".to_string()),
|
|
JobTriggerKind::Schedule,
|
|
);
|
|
|
|
let kept = authed.trigger_or_fallback(Some(schedule)).unwrap();
|
|
assert_eq!(kept.trigger_kind.to_string(), "schedule");
|
|
assert_eq!(kept.trigger_path.as_deref(), Some("u/alice/nightly"));
|
|
}
|
|
|
|
// Regression tests for the Preview path traversal: a Preview's path skips the
|
|
// DB `proper_id` CHECK and reaches the worker, where it builds on-disk module
|
|
// dirs. Traversal must be rejected even for admins, who otherwise bypass the
|
|
// namespace/folder access check.
|
|
#[test]
|
|
fn preview_path_rejects_traversal() {
|
|
let admin = ApiAuthed { is_admin: true, username: "admin".into(), ..Default::default() };
|
|
for path in [
|
|
"u/admin/../../../../../../tmp/evil/payload",
|
|
"../../tmp/evil",
|
|
"/tmp/evil",
|
|
"u/admin/ok/../../../../etc/cron.d/x",
|
|
] {
|
|
assert!(
|
|
require_path_read_access_for_preview(&admin, &Some(path.to_string())).is_err(),
|
|
"expected traversal path to be rejected: {path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn preview_path_allows_legitimate_paths() {
|
|
let alice = ApiAuthed { username: "alice".into(), ..Default::default() };
|
|
assert!(require_path_read_access_for_preview(&alice, &None).is_ok());
|
|
assert!(require_path_read_access_for_preview(&alice, &Some(String::new())).is_ok());
|
|
assert!(
|
|
require_path_read_access_for_preview(&alice, &Some("u/alice/my_script".into())).is_ok()
|
|
);
|
|
|
|
let admin = ApiAuthed { is_admin: true, username: "admin".into(), ..Default::default() };
|
|
assert!(
|
|
require_path_read_access_for_preview(&admin, &Some("hub/foo/bar/baz".into())).is_ok()
|
|
);
|
|
// `..` only as a substring of a segment is a valid name, not traversal.
|
|
assert!(
|
|
require_path_read_access_for_preview(&admin, &Some("f/team/my..script".into())).is_ok()
|
|
);
|
|
}
|
|
|
|
// Regression for WIN-2157: a malformed single-segment path (e.g. a draft
|
|
// saved at a bare `u`) must return a clear error, not panic on the
|
|
// `splitted[1]` index. Non-admins reach this branch (admins short-circuit).
|
|
#[test]
|
|
fn require_owner_of_path_rejects_malformed_path_without_panicking() {
|
|
let alice = ApiAuthed { username: "alice".into(), ..Default::default() };
|
|
for path in ["u", "f", "g", "nonsense"] {
|
|
let err =
|
|
require_owner_of_path(&alice, path).expect_err("malformed path must be rejected");
|
|
assert!(
|
|
matches!(err, Error::BadRequest(_)),
|
|
"expected BadRequest for '{path}', got {err:?}"
|
|
);
|
|
}
|
|
// A well-formed foreign path returns the owner error, not a malformed one.
|
|
assert!(require_owner_of_path(&alice, "u/bob/script").is_err());
|
|
// The user's own namespace resolves.
|
|
assert!(require_owner_of_path(&alice, "u/alice/script").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn predicate_no_scopes_allows_all() {
|
|
let authed = authed_with_scopes(None);
|
|
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
|
assert!(allowed("u/alice/anything"));
|
|
assert!(allowed("u/bob/other"));
|
|
}
|
|
|
|
#[test]
|
|
fn predicate_tag_filter_only_allows_all() {
|
|
let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"]));
|
|
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
|
assert!(allowed("u/alice/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn predicate_single_resource_scope_filters_others() {
|
|
// Regression test for WIN-1981: a token scoped to one resource must
|
|
// not match unrelated paths in listings (e.g. /resources/list_search).
|
|
let authed = authed_with_scopes(Some(vec!["resources:read:u/alice/allowed_resource"]));
|
|
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
|
assert!(allowed("u/alice/allowed_resource"));
|
|
assert!(!allowed("u/alice/other_resource"));
|
|
assert!(!allowed("u/bob/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn predicate_wildcard_scope_matches_subtree() {
|
|
let authed = authed_with_scopes(Some(vec!["resources:read:f/team/*"]));
|
|
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
|
assert!(allowed("f/team/db"));
|
|
assert!(allowed("f/team/sub/nested"));
|
|
assert!(!allowed("f/other/db"));
|
|
}
|
|
|
|
#[test]
|
|
fn predicate_wrong_domain_is_rejected() {
|
|
let authed = authed_with_scopes(Some(vec!["variables:read:u/alice/secret"]));
|
|
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
|
assert!(!allowed("u/alice/secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn predicate_write_implies_read() {
|
|
let authed = authed_with_scopes(Some(vec!["resources:write:u/alice/foo"]));
|
|
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
|
assert!(allowed("u/alice/foo"));
|
|
assert!(!allowed("u/alice/bar"));
|
|
}
|
|
|
|
// The SQL-pushable filter must grant exactly what the post-fetch predicate does:
|
|
// any divergence either leaks/over-grants (filter looser) or hides authorized
|
|
// rows (filter tighter). Cross-check both over a matrix of scope sets and paths.
|
|
#[test]
|
|
fn scope_path_filter_agrees_with_predicate() {
|
|
let scope_sets: Vec<Option<Vec<&str>>> = vec![
|
|
None,
|
|
Some(vec![]),
|
|
Some(vec!["if_jobs:filter_tags:default"]),
|
|
Some(vec!["resources:read"]),
|
|
Some(vec!["resources:read:*"]),
|
|
Some(vec!["resources:read:u/alice/foo"]),
|
|
Some(vec!["resources:read:f/team/*"]),
|
|
Some(vec!["resources:write:f/team/*"]),
|
|
Some(vec!["resources:read:u/alice/foo,f/team/*"]),
|
|
Some(vec!["variables:read:f/team/*"]),
|
|
Some(vec!["resources:read:f/team", "resources:read:f/team2/*"]),
|
|
];
|
|
let paths = [
|
|
"u/alice/foo",
|
|
"u/alice/foobar",
|
|
"u/bob/foo",
|
|
"f/team",
|
|
"f/team/db",
|
|
"f/team/sub/nested",
|
|
"f/team2",
|
|
"f/other/db",
|
|
];
|
|
for scopes in &scope_sets {
|
|
let authed = authed_with_scopes(scopes.clone());
|
|
let predicate = build_scope_path_predicate(&authed, "resources", "read");
|
|
let filter = build_scope_path_filter(&authed, "resources", "read");
|
|
for path in paths {
|
|
assert_eq!(
|
|
filter.allows(path),
|
|
predicate(path),
|
|
"mismatch for scopes {scopes:?} path {path}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn opt_scopes(scopes: Option<Vec<&str>>) -> Option<Vec<String>> {
|
|
scopes.map(|v| v.into_iter().map(String::from).collect())
|
|
}
|
|
|
|
// Regression tests for WIN-1999: scoped user tokens must not be able to
|
|
// mint or rescope credentials with broader privileges than themselves.
|
|
|
|
#[test]
|
|
fn unscoped_caller_can_grant_anything() {
|
|
let authed = authed_with_scopes(None);
|
|
assert!(ensure_scopes_within_caller(&authed, None).is_ok());
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn filter_tags_only_caller_is_unrestricted_on_domain_action_dimension() {
|
|
// The domain/action/resource subset check treats filter-tags-only as
|
|
// unrestricted, mirroring check_scopes/check_route_access. The tag
|
|
// dimension is checked separately (see filter_tags_dimension_is_monotonic).
|
|
let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["users:write", "if_jobs:filter_tags:default"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn filter_tags_dimension_is_monotonic() {
|
|
// Caller restricted to tag fence "a" cannot drop the fence …
|
|
let single = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a"]));
|
|
assert!(ensure_scopes_within_caller(&single, None).is_err());
|
|
assert!(
|
|
ensure_scopes_within_caller(&single, opt_scopes(Some(vec!["users:read"])).as_deref())
|
|
.is_err(),
|
|
"minting a token without filter_tags must be rejected"
|
|
);
|
|
// … cannot widen to a tag it lacks …
|
|
assert!(ensure_scopes_within_caller(
|
|
&single,
|
|
opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref()
|
|
)
|
|
.is_err());
|
|
// … and cannot mint a token fenced on a disjoint tag.
|
|
assert!(ensure_scopes_within_caller(
|
|
&single,
|
|
opt_scopes(Some(vec!["if_jobs:filter_tags:b"])).as_deref()
|
|
)
|
|
.is_err());
|
|
// Narrowing or matching the tag fence is allowed.
|
|
let multi = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a,b"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&multi,
|
|
opt_scopes(Some(vec!["if_jobs:filter_tags:a"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
assert!(ensure_scopes_within_caller(
|
|
&multi,
|
|
opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
// A caller with a real scope plus a tag fence cannot drop just the fence.
|
|
let mixed = authed_with_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&mixed,
|
|
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
|
|
)
|
|
.is_err());
|
|
assert!(ensure_scopes_within_caller(
|
|
&mixed,
|
|
opt_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
// An unrestricted caller may grant filter_tags freely.
|
|
let unscoped = authed_with_scopes(None);
|
|
assert!(ensure_scopes_within_caller(
|
|
&unscoped,
|
|
opt_scopes(Some(vec!["if_jobs:filter_tags:x"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_caller_cannot_mint_unscoped_token() {
|
|
// Primitive 2 in the report: a users:write token minting an unscoped token.
|
|
let authed = authed_with_scopes(Some(vec!["users:write"]));
|
|
assert!(ensure_scopes_within_caller(&authed, None).is_err());
|
|
// Empty scope list is effectively unscoped and must also be rejected.
|
|
assert!(ensure_scopes_within_caller(&authed, Some(&[])).is_err());
|
|
// A scope list of only tag filters is effectively unscoped too.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["if_jobs:filter_tags:default"])).as_deref()
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_caller_cannot_remove_its_own_scopes() {
|
|
// Primitive 3 in the report: a users:write token setting its scopes to null.
|
|
let authed = authed_with_scopes(Some(vec!["users:write"]));
|
|
assert!(ensure_scopes_within_caller(&authed, None).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_caller_cannot_grant_scope_it_lacks() {
|
|
let authed = authed_with_scopes(Some(vec!["users:write"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_caller_can_grant_subset_of_own_scopes() {
|
|
let authed = authed_with_scopes(Some(vec!["users:write", "jobs:run:scripts"]));
|
|
// Equal scope is allowed.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
// write implies read, so a narrower read scope is allowed.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["users:read"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
// Tag filters narrow further and are always permitted.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["users:read", "if_jobs:filter_tags:default"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
// A `*` path grant spans the domain, so it may mint the unqualified form a
|
|
// whole-collection read requires; a listed path may not.
|
|
let wildcard = authed_with_scopes(Some(vec!["resources:read:*"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&wildcard,
|
|
opt_scopes(Some(vec!["resources:read"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
let path_scoped = authed_with_scopes(Some(vec!["resources:read:f/team/db"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&path_scoped,
|
|
opt_scopes(Some(vec!["resources:read"])).as_deref()
|
|
)
|
|
.is_err());
|
|
// Apps `write` covers `run`, so an app-editor token can mint the run-only
|
|
// credential for the same app — but only within its own resource subtree,
|
|
// and the equivalence stays Apps-only.
|
|
let app_editor = authed_with_scopes(Some(vec!["apps:write:u/me/a", "jobs:write"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&app_editor,
|
|
opt_scopes(Some(vec!["apps:run:u/me/a"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
assert!(ensure_scopes_within_caller(
|
|
&app_editor,
|
|
opt_scopes(Some(vec!["apps:run:u/me/b"])).as_deref()
|
|
)
|
|
.is_err());
|
|
assert!(ensure_scopes_within_caller(
|
|
&app_editor,
|
|
opt_scopes(Some(vec!["jobs:run"])).as_deref()
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_caller_cannot_broaden_resource_scope() {
|
|
let authed = authed_with_scopes(Some(vec!["scripts:read:f/team/*"]));
|
|
// Narrower resource within the subtree is allowed.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["scripts:read:f/team/sub"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
// A nested subtree within the caller's subtree is allowed.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["scripts:read:f/team/sub/*"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
// The subtree root itself is allowed.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["scripts:read:f/team"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
// A path outside the subtree is rejected.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["scripts:read:f/other/x"])).as_deref()
|
|
)
|
|
.is_err());
|
|
// read caller cannot grant write.
|
|
assert!(ensure_scopes_within_caller(
|
|
&authed,
|
|
opt_scopes(Some(vec!["scripts:write:f/team/db"])).as_deref()
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_scopes_require_byte_identical_match() {
|
|
// Regression for the access-grant-OR vs runtime-MCP-parser confusion:
|
|
// ScopeDefinition treats `mcp:scripts` as an unrestricted-resource scope
|
|
// and `mcp:scripts:*` as a strictly narrower one, so generic containment
|
|
// would silently allow widening. The MCP runtime however ignores
|
|
// `mcp:scripts` (unrecognized) while `mcp:scripts:*` grants all scripts.
|
|
// Legitimate MCP token issuance is the OAuth gateway, not these
|
|
// user-token endpoints, so MCP scopes must match the caller verbatim.
|
|
|
|
// The bypass the reviewer flagged: malformed `mcp:scripts` would widen
|
|
// into the real `mcp:scripts:*` under generic containment.
|
|
let bypass = authed_with_scopes(Some(vec!["users:write", "mcp:scripts"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&bypass,
|
|
opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref()
|
|
)
|
|
.is_err());
|
|
|
|
// A caller without any MCP scope cannot grant one (widening on the MCP
|
|
// dimension), even if the rest of the requested scopes are within reach.
|
|
let no_mcp = authed_with_scopes(Some(vec!["users:write"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&no_mcp,
|
|
opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref()
|
|
)
|
|
.is_err());
|
|
|
|
// Byte-identical MCP scope passes; an additional non-matching MCP scope
|
|
// alongside it does not.
|
|
let mcp_caller = authed_with_scopes(Some(vec!["mcp:scripts:*"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&mcp_caller,
|
|
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
assert!(ensure_scopes_within_caller(
|
|
&mcp_caller,
|
|
opt_scopes(Some(vec!["mcp:scripts:*", "mcp:flows:*"])).as_deref()
|
|
)
|
|
.is_err());
|
|
|
|
// Even a narrowing within MCP semantics (`mcp:all` → `mcp:scripts:*`)
|
|
// is rejected by the byte-identical rule. This is intentional — these
|
|
// endpoints are not the legitimate path for narrowing MCP tokens.
|
|
let mcp_all = authed_with_scopes(Some(vec!["mcp:all"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&mcp_all,
|
|
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_caller_cannot_escalate_to_wildcard_or_superset() {
|
|
// Regression for the access-grant-OR vs subset-containment confusion:
|
|
// ScopeDefinition::includes would (incorrectly) allow all of these.
|
|
let star = authed_with_scopes(Some(vec!["scripts:read:f/team/a"]));
|
|
// Minting `*` from a single-path scope must be rejected.
|
|
assert!(ensure_scopes_within_caller(
|
|
&star,
|
|
opt_scopes(Some(vec!["scripts:read:*"])).as_deref()
|
|
)
|
|
.is_err());
|
|
// Minting a broader subtree must be rejected.
|
|
assert!(ensure_scopes_within_caller(
|
|
&star,
|
|
opt_scopes(Some(vec!["scripts:read:f/team/*"])).as_deref()
|
|
)
|
|
.is_err());
|
|
|
|
// A comma-separated list that adds an uncovered resource must be rejected,
|
|
// even though one element overlaps the caller's scope.
|
|
let list = authed_with_scopes(Some(vec!["scripts:read:f/team/a"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&list,
|
|
opt_scopes(Some(vec!["scripts:read:f/team/a,f/other/b"])).as_deref()
|
|
)
|
|
.is_err());
|
|
|
|
// A subset of a multi-resource caller scope is allowed.
|
|
let multi = authed_with_scopes(Some(vec!["scripts:read:f/team/a,f/team/b"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&multi,
|
|
opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
|
|
// A wildcard caller covers any subset, but not `*`-less escalation rules apply
|
|
// only when the caller itself lacks `*`.
|
|
let wildcard = authed_with_scopes(Some(vec!["scripts:read:*"]));
|
|
assert!(ensure_scopes_within_caller(
|
|
&wildcard,
|
|
opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref()
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|