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>
1021 lines
37 KiB
Rust
1021 lines
37 KiB
Rust
use std::{
|
|
hash::DefaultHasher,
|
|
sync::atomic::{AtomicI64, Ordering},
|
|
};
|
|
|
|
use anyhow::Context;
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use quick_cache::sync::Cache;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
db::{Authed, AuthedRef},
|
|
error::{Error, Result},
|
|
jwt,
|
|
users::{SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL},
|
|
utils::WarnAfterExt,
|
|
DB,
|
|
};
|
|
|
|
/// Whether `label` denotes a user-created token rather than a system token
|
|
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
|
|
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`). System-token labels are load-bearing —
|
|
/// session cleanup, super_admin propagation, expiry notifications and username overrides
|
|
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
|
|
/// a user token.
|
|
///
|
|
/// This is the canonical copy. When updating it, also update its mirrors:
|
|
/// - the `update_token_label` editability guard (SQL `WHERE`) in
|
|
/// windmill-api-users/src/users.rs
|
|
/// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte
|
|
pub fn is_user_token(label: Option<&str>) -> bool {
|
|
match label {
|
|
None => true,
|
|
Some(l) => {
|
|
// `ephemeral` is matched case-insensitively to agree exactly with the
|
|
// frontend mirror (`label.toLowerCase().startsWith('ephemeral')`) and
|
|
// the SQL `lower(label) NOT LIKE 'ephemeral%'` guard.
|
|
l != "session"
|
|
&& l != GUEST_SESSION_LABEL
|
|
&& !l.to_lowercase().starts_with("ephemeral")
|
|
&& l != "debugger-token"
|
|
&& !l.starts_with("mcp-oauth-")
|
|
// Short-lived tokens the server mints per app open or per service-account
|
|
// impersonation (EE `users_ee.rs`) and nobody manages, so an expiry warning
|
|
// for one is noise.
|
|
&& !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX)
|
|
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
|
|
&& !l.starts_with("impersonation:")
|
|
&& !l.starts_with(CLI_LOGIN_TOKEN_LABEL_PREFIX)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How far ahead of a user token's expiration its owner is warned (`check_expiring_tokens` in
|
|
/// the monitor). A token whose whole lifetime fits in this window gets no warning at all: it
|
|
/// would arrive minutes after creation, about a lifetime its creator just picked. Its
|
|
/// "expired and deleted" notice still goes out.
|
|
pub const TOKEN_EXPIRY_WARNING_DAYS: i32 = 7;
|
|
|
|
/// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe
|
|
/// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
|
|
pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
|
|
|
|
/// Label prefix, followed by the app path, of the token a raw app's bundle uses for the
|
|
/// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
|
|
pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
|
|
|
|
/// Label prefix, followed by the username, of the token the CLI authorization page mints for
|
|
/// `wmill` logins. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out:
|
|
/// the CLI signs in again on its own once that token expires, so an expiry email for it asks
|
|
/// the user to do nothing. Not in [`is_server_minted_label`], since the page mints it through
|
|
/// `/users/tokens/create`.
|
|
pub const CLI_LOGIN_TOKEN_LABEL_PREFIX: &str = "cli-login:";
|
|
|
|
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
|
|
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
|
|
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
|
|
/// `debugger-token` and `ephemeral-test-connection: *` are minted by the editor, the debugger
|
|
/// and object-storage connection tests through that same handler, so reserving them would
|
|
/// break those features.
|
|
///
|
|
/// `username_override_from_label` trusts a label to name the entity acting only if it is in
|
|
/// here, so anything added must be unmintable by a member.
|
|
pub fn is_server_minted_label(label: &str) -> bool {
|
|
label.starts_with("ephemeral-webhook-")
|
|
|| label.starts_with("ephemeral-script-end-user-")
|
|
|| label == "ephemeral-script"
|
|
|| label == "session"
|
|
|| label == GUEST_SESSION_LABEL
|
|
|| label.starts_with("mcp-oauth-")
|
|
}
|
|
|
|
/// Label on a guest session (the `guest` app execution mode). This is the *grant*:
|
|
/// `AuthCache` will resolve a token carrying it into an identity with no account behind
|
|
/// it, which nothing else can do. It must therefore stay unforgeable, which is what
|
|
/// listing it in [`is_server_minted_label`] buys — `/users/tokens/create` refuses it.
|
|
///
|
|
/// Do not move this test onto the token's scopes. Scopes on a user-minted token are
|
|
/// caller-supplied and only ever *narrow* (`app_embed`, `raw_app_sdk`), so a scope
|
|
/// that granted non-member access would be free for anyone to declare.
|
|
pub const GUEST_SESSION_LABEL: &str = "guest_session";
|
|
|
|
/// Whether `label` marks a guest session. See [`GUEST_SESSION_LABEL`].
|
|
///
|
|
/// Reserved in [`is_user_token`] as well as [`is_server_minted_label`]: the former
|
|
/// gates relabelling, and a user token that could be relabelled *into* this
|
|
/// namespace would become a guest session with no workspace pin — one that
|
|
/// authenticates everywhere.
|
|
pub fn is_guest_session_label(label: Option<&str>) -> bool {
|
|
label == Some(GUEST_SESSION_LABEL)
|
|
}
|
|
|
|
/// Whether `path` can be spliced into a scope as one literal resource. The scope
|
|
/// grammar reserves three characters: `:` separates the parts, `,` separates
|
|
/// resources, `*` is a wildcard. App paths are otherwise free-form (spaces, `@`). A
|
|
/// leading `/` is refused too: routes strip it, so the scope would never match.
|
|
pub fn is_scope_literal_path(path: &str) -> bool {
|
|
!path.is_empty()
|
|
&& !path.starts_with('/')
|
|
&& !path.chars().any(|c| matches!(c, ':' | ',' | '*'))
|
|
}
|
|
|
|
/// Whether `label` is the one minted for a browser session at login. [`is_server_minted_label`]
|
|
/// stops a member minting it directly, but `/users/refresh_token` hands one to any authenticated
|
|
/// caller, so this attributes a request to the UI without proving it: never gate authority on it.
|
|
pub fn is_session_label(label: Option<&str>) -> bool {
|
|
label == Some("session")
|
|
}
|
|
|
|
/// Hash a raw token using SHA-256 (hex-encoded, 64 chars).
|
|
/// Used to store and look up tokens without keeping plaintext in the DB.
|
|
pub fn hash_token(token: &str) -> String {
|
|
crate::utils::calculate_hash(token)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct IdToken {
|
|
token: String,
|
|
expiration: DateTime<Utc>,
|
|
}
|
|
|
|
pub const TOKEN_PREFIX_LEN: usize = 10;
|
|
|
|
/// Safely extract the token prefix (first TOKEN_PREFIX_LEN chars).
|
|
/// Returns the full token if it's shorter than TOKEN_PREFIX_LEN, preventing panics.
|
|
pub fn safe_token_prefix(token: &str) -> String {
|
|
token.get(..TOKEN_PREFIX_LEN).unwrap_or(token).to_string()
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
// Cache for script hash permissions - (ApiAuthed hash, script_hash) -> permission result
|
|
pub static ref HASH_PERMS_CACHE: PermsCache = PermsCache::new();
|
|
pub static ref FLOW_PERMS_CACHE: PermsCache = PermsCache::new();
|
|
}
|
|
|
|
pub struct PermsCache(Cache<(u64, u64), ()>, AtomicI64);
|
|
|
|
use std::hash::Hash;
|
|
use std::hash::Hasher;
|
|
|
|
impl PermsCache {
|
|
pub fn compute_hash(authed: &AuthedRef) -> u64 {
|
|
let mut hasher = DefaultHasher::new();
|
|
authed.username.hash(&mut hasher);
|
|
authed.folders.hash(&mut hasher);
|
|
authed.groups.hash(&mut hasher);
|
|
authed.is_admin.hash(&mut hasher);
|
|
hasher.finish()
|
|
}
|
|
}
|
|
|
|
pub const PERMS_CACHE_EXPIRATION_SECONDS: i64 = 60 * 60;
|
|
|
|
impl PermsCache {
|
|
pub fn new() -> Self {
|
|
PermsCache(
|
|
Cache::new(10000),
|
|
AtomicI64::new(chrono::Utc::now().timestamp() as i64),
|
|
)
|
|
}
|
|
|
|
pub fn check_perms_in_cache<'e, T: Into<u64>>(
|
|
&self,
|
|
authed: &'e AuthedRef<'e>,
|
|
key: T,
|
|
) -> (bool, u64) {
|
|
// Clear cache every hour
|
|
if self.1.load(Ordering::Relaxed)
|
|
< chrono::Utc::now().timestamp() - PERMS_CACHE_EXPIRATION_SECONDS
|
|
{
|
|
self.0.clear();
|
|
self.1
|
|
.store(chrono::Utc::now().timestamp() as i64, Ordering::Relaxed);
|
|
}
|
|
// Create hash of the ApiAuthed struct for caching
|
|
let authed_hash = Self::compute_hash(authed);
|
|
|
|
let key = key.into();
|
|
tracing::debug!(
|
|
"Checking cache for authed hash {authed_hash} and script hash {}",
|
|
key
|
|
);
|
|
// Check cache first
|
|
if let Some(_) = self.0.get(&(authed_hash, key)) {
|
|
tracing::debug!("Cached result for authed hash {authed_hash}",);
|
|
return (true, authed_hash);
|
|
}
|
|
|
|
return (false, authed_hash);
|
|
}
|
|
|
|
pub fn insert<'e, T: Into<u64>>(&self, authed_hash: u64, key: T) {
|
|
let key = key.into();
|
|
tracing::debug!("Inserting authed hash {authed_hash} and key {}", key);
|
|
self.0.insert((authed_hash, key), ());
|
|
}
|
|
}
|
|
|
|
/// Check a user's access level against an `extra_perms` JSONB object.
|
|
///
|
|
/// Returns `None` if the user has no matching entry (no access).
|
|
/// Returns `Some(true)` if the user (or any of their groups) has write access.
|
|
/// Returns `Some(false)` if the user (or any of their groups) has read-only access.
|
|
pub fn check_extra_perms(
|
|
extra_perms: &serde_json::Map<String, serde_json::Value>,
|
|
username: &str,
|
|
groups: &[String],
|
|
) -> Option<bool> {
|
|
// Check direct user permission
|
|
use crate::users::{PERMISSIONED_AS_GROUP_PREFIX, PERMISSIONED_AS_USER_PREFIX};
|
|
let user_key = if username.starts_with(PERMISSIONED_AS_USER_PREFIX) {
|
|
username.to_string()
|
|
} else {
|
|
format!("{PERMISSIONED_AS_USER_PREFIX}{username}")
|
|
};
|
|
if let Some(v) = extra_perms.get(&user_key) {
|
|
return Some(v.as_bool().unwrap_or(false));
|
|
}
|
|
|
|
// Check group permissions — return highest access level found
|
|
let mut found = false;
|
|
let mut write = false;
|
|
for g in groups {
|
|
let key = if g.starts_with(PERMISSIONED_AS_GROUP_PREFIX) {
|
|
g.to_string()
|
|
} else {
|
|
format!("{PERMISSIONED_AS_GROUP_PREFIX}{g}")
|
|
};
|
|
if let Some(v) = extra_perms.get(&key) {
|
|
found = true;
|
|
if v.as_bool().unwrap_or(false) {
|
|
write = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if found {
|
|
Some(write)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn has_expired(expiration_time: DateTime<Utc>, take: Option<Duration>) -> bool {
|
|
let now = Utc::now();
|
|
|
|
let expiration = match take {
|
|
Some(duration) => expiration_time - duration,
|
|
None => expiration_time,
|
|
};
|
|
|
|
now > expiration
|
|
}
|
|
|
|
impl From<IdToken> for String {
|
|
fn from(value: IdToken) -> Self {
|
|
value.token
|
|
}
|
|
}
|
|
|
|
impl ToString for IdToken {
|
|
fn to_string(&self) -> String {
|
|
self.token.clone()
|
|
}
|
|
}
|
|
|
|
impl IdToken {
|
|
pub fn new(token: String, expiration: DateTime<Utc>) -> Self {
|
|
Self { token, expiration }
|
|
}
|
|
|
|
pub fn token(&self) -> &str {
|
|
&self.token
|
|
}
|
|
pub fn expiration(&self) -> &DateTime<Utc> {
|
|
&self.expiration
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize)]
|
|
pub struct JWTAuthClaims {
|
|
pub email: String,
|
|
pub username: String,
|
|
pub is_admin: bool,
|
|
pub is_operator: bool,
|
|
pub groups: Vec<String>,
|
|
pub folders: Vec<(String, bool, bool)>,
|
|
pub label: Option<String>,
|
|
pub workspace_id: Option<String>,
|
|
pub workspace_ids: Option<Vec<String>>,
|
|
pub exp: usize,
|
|
pub job_id: Option<String>,
|
|
pub scopes: Option<Vec<String>>,
|
|
pub audit_span: Option<String>,
|
|
}
|
|
|
|
impl JWTAuthClaims {
|
|
pub fn allowed_in_workspace(&self, w_id: &str) -> bool {
|
|
self.workspace_id
|
|
.as_ref()
|
|
.is_some_and(|token_w_id| w_id == token_w_id)
|
|
|| self
|
|
.workspace_ids
|
|
.as_ref()
|
|
.is_some_and(|token_w_ids| token_w_ids.iter().any(|token_w_id| w_id == token_w_id))
|
|
}
|
|
|
|
pub fn compute_ext_jwt_hash(&self) -> i64 {
|
|
let mut hasher = DefaultHasher::new();
|
|
self.email.hash(&mut hasher);
|
|
self.username.hash(&mut hasher);
|
|
self.is_admin.hash(&mut hasher);
|
|
self.is_operator.hash(&mut hasher);
|
|
self.groups.hash(&mut hasher);
|
|
self.folders.hash(&mut hasher);
|
|
self.workspace_id.hash(&mut hasher);
|
|
self.workspace_ids.hash(&mut hasher);
|
|
self.label.hash(&mut hasher);
|
|
self.scopes.hash(&mut hasher);
|
|
hasher.finish() as i64
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
pub struct JobPerms {
|
|
pub email: String,
|
|
pub username: String,
|
|
pub is_admin: bool,
|
|
pub is_operator: bool,
|
|
pub groups: Vec<String>,
|
|
pub folders: Vec<serde_json::Value>,
|
|
pub end_user_email: Option<String>,
|
|
}
|
|
|
|
impl From<JobPerms> for Authed {
|
|
fn from(value: JobPerms) -> Self {
|
|
Self {
|
|
email: value.email,
|
|
username: value.username,
|
|
is_admin: value.is_admin,
|
|
is_operator: value.is_operator,
|
|
groups: value.groups,
|
|
folders: value
|
|
.folders
|
|
.into_iter()
|
|
.filter_map(|x| serde_json::from_value::<(String, bool, bool)>(x).ok())
|
|
.collect(),
|
|
scopes: None,
|
|
token_prefix: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn is_super_admin_email<'c>(db: impl sqlx::PgExecutor<'c>, email: &str) -> Result<bool> {
|
|
if email == SUPERADMIN_SECRET_EMAIL || email == SUPERADMIN_NOTIFICATION_EMAIL {
|
|
return Ok(true);
|
|
}
|
|
|
|
let is_admin = sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email)
|
|
.fetch_optional(db)
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("fetching super admin: {e:#}")))?
|
|
.unwrap_or(false);
|
|
|
|
Ok(is_admin)
|
|
}
|
|
|
|
/// The three reserved internal identities that grant instance-superadmin at
|
|
/// execution: `superadmin_secret@` / `superadmin_notification@` (matched on the
|
|
/// email) and `superadmin_sync@` (matched on `permissioned_as`). They belong to
|
|
/// no real user, so a stored `on_behalf_of` (app policy, flow/script,
|
|
/// schedule, trigger) must never carry one as either field — it would be a
|
|
/// forged superadmin run identity. Mirror of the `is_super_admin` derivation in
|
|
/// [`fetch_authed_from_permissioned_as_inner`].
|
|
pub fn is_reserved_on_behalf_of_identity(
|
|
permissioned_as: Option<&str>,
|
|
on_behalf_of_email: Option<&str>,
|
|
) -> bool {
|
|
const RESERVED: [&str; 3] = [
|
|
SUPERADMIN_SECRET_EMAIL,
|
|
SUPERADMIN_NOTIFICATION_EMAIL,
|
|
SUPERADMIN_SYNC_EMAIL,
|
|
];
|
|
[permissioned_as, on_behalf_of_email]
|
|
.into_iter()
|
|
.flatten()
|
|
.any(|v| RESERVED.contains(&v))
|
|
}
|
|
|
|
/// Guard a caller-supplied `on_behalf_of` before it is persisted on a deployable
|
|
/// object (app policy, flow/script, schedule, trigger): reject the reserved
|
|
/// internal sentinels, which no legitimate deploy ever carries. The actual
|
|
/// escalation is closed at execution by the job-token cap in
|
|
/// [`require_super_admin`] — even a superadmin `on_behalf_of` yields a token
|
|
/// capped at workspace admin — so this is a cheap, non-breaking early guard, not
|
|
/// the primary defense. It deliberately does *not* restrict deploying on behalf
|
|
/// of a real user (including a real superadmin, e.g. git-sync of
|
|
/// superadmin-authored content), which is the intended `wm_deployers` capability.
|
|
pub fn validate_on_behalf_of(
|
|
permissioned_as: Option<&str>,
|
|
on_behalf_of_email: Option<&str>,
|
|
) -> Result<()> {
|
|
if is_reserved_on_behalf_of_identity(permissioned_as, on_behalf_of_email) {
|
|
return Err(Error::BadRequest(
|
|
"on_behalf_of cannot be a reserved internal identity".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn is_devops_email(db: &DB, email: &str) -> Result<bool> {
|
|
if is_super_admin_email(db, email).await? {
|
|
return Ok(true);
|
|
}
|
|
|
|
let is_devops = sqlx::query_scalar!("SELECT devops FROM password WHERE email = $1", email)
|
|
.fetch_optional(db)
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("fetching super admin: {e:#}")))?
|
|
.unwrap_or(false);
|
|
|
|
Ok(is_devops)
|
|
}
|
|
|
|
pub fn permissioned_as_to_username(permissioned_as: &str) -> String {
|
|
use crate::users::{PERMISSIONED_AS_USER_PREFIX, USERNAME_GROUP_PREFIX};
|
|
if let Some(name) = permissioned_as.strip_prefix(PERMISSIONED_AS_USER_PREFIX) {
|
|
name.to_string()
|
|
} else if let Some(name) =
|
|
permissioned_as.strip_prefix(crate::users::PERMISSIONED_AS_GROUP_PREFIX)
|
|
{
|
|
format!("{}{}", USERNAME_GROUP_PREFIX, name)
|
|
} else {
|
|
permissioned_as.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn fetch_authed_from_permissioned_as<'a, A>(
|
|
permissioned_as: &'a str,
|
|
email: &'a str,
|
|
w_id: &'a str,
|
|
db: A,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Authed>> + Send + 'a>>
|
|
where
|
|
A: sqlx::Acquire<'a, Database = sqlx::Postgres> + Send + 'a,
|
|
{
|
|
Box::pin(async move {
|
|
let mut conn = db
|
|
.acquire()
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("acquiring connection: {e:#}")))?;
|
|
|
|
fetch_authed_from_permissioned_as_inner(permissioned_as, email, w_id, &mut *conn).await
|
|
})
|
|
}
|
|
|
|
async fn fetch_authed_from_permissioned_as_inner(
|
|
permissioned_as: &str,
|
|
email: &str,
|
|
w_id: &str,
|
|
conn: &mut sqlx::PgConnection,
|
|
) -> Result<Authed> {
|
|
// The `usr` row is the live binding between a `u/` principal and an address, and it is read
|
|
// here anyway for the workspace role. Callers may hand us a cached address, so read it before
|
|
// anything is granted: `super_admin` and `email_to_igroup` below are keyed on the address
|
|
// while the role is keyed on the principal, and an address that no longer belongs to this
|
|
// principal — a username freed and reassigned while its previous holder keeps a privileged
|
|
// account — would mix one account's role with another's instance privileges.
|
|
let member = match permissioned_as.split_once('/') {
|
|
Some(("u", name)) => {
|
|
sqlx::query!(
|
|
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
|
|
workspace_id = $2 AND disabled = false",
|
|
name,
|
|
&w_id
|
|
)
|
|
.fetch_optional(&mut *conn)
|
|
.await?
|
|
}
|
|
_ => None,
|
|
};
|
|
let resolved_email;
|
|
let email = match member.as_ref() {
|
|
Some(m) => m.email.as_str(),
|
|
// No enabled `usr` row. Resolve as `resolve_username_to_email` does: a disabled member's
|
|
// own row still wins over the `password` superadmin fallback, so it can never resolve to
|
|
// an unrelated superadmin who shares the username (workspace usernames are only unique per
|
|
// workspace). Off the member path, which is why it is worth a query that path skips.
|
|
None => match permissioned_as.split_once('/') {
|
|
Some(("u", name)) => {
|
|
resolved_email =
|
|
crate::users::resolve_username_to_email(w_id, name, &mut *conn).await?;
|
|
// No live binding at all: the supplied address stands. A cached one is at most one
|
|
// notify poll stale; accepted, see `users::get_email_from_permissioned_as`.
|
|
resolved_email.as_deref().unwrap_or(email)
|
|
}
|
|
_ => email,
|
|
},
|
|
};
|
|
|
|
let is_super_admin = permissioned_as == SUPERADMIN_SYNC_EMAIL
|
|
|| email == SUPERADMIN_SECRET_EMAIL
|
|
|| email == SUPERADMIN_NOTIFICATION_EMAIL
|
|
|| sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email)
|
|
.fetch_optional(&mut *conn)
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("fetching super admin: {e:#}")))?
|
|
.unwrap_or(false);
|
|
|
|
if let Some((prefix, name)) = permissioned_as.split_once('/') {
|
|
if prefix == "u" {
|
|
let (is_admin, is_operator) = if is_super_admin {
|
|
(true, false)
|
|
} else if let Some(m) = member.as_ref() {
|
|
(m.is_admin, m.operator)
|
|
} else {
|
|
return Err(Error::NotFound(format!(
|
|
"user {name} not found in workspace {w_id}"
|
|
)));
|
|
};
|
|
|
|
let groups = get_groups_for_user(w_id, &name, email, &mut *conn).await?;
|
|
|
|
let folders = get_folders_for_user(w_id, &name, &groups, &mut *conn).await?;
|
|
|
|
Ok(Authed {
|
|
email: email.to_string(),
|
|
username: name.to_string(),
|
|
is_admin,
|
|
is_operator,
|
|
groups,
|
|
folders,
|
|
scopes: None,
|
|
token_prefix: None,
|
|
})
|
|
} else {
|
|
let groups = vec![name.to_string()];
|
|
let folders = get_folders_for_user(&w_id, "", &groups, &mut *conn).await?;
|
|
Ok(Authed {
|
|
email: email.to_string(),
|
|
username: format!("{}{name}", crate::users::USERNAME_GROUP_PREFIX),
|
|
is_admin: false,
|
|
groups,
|
|
is_operator: false,
|
|
folders,
|
|
scopes: None,
|
|
token_prefix: None,
|
|
})
|
|
}
|
|
} else {
|
|
// Bare (no `u/`|`g/` prefix) permissioned_as is reached for superadmins
|
|
// whose identifier is their email (they are not a workspace member). Use
|
|
// the instance-derived username when available so no email leaks
|
|
// downstream as the acting username.
|
|
let username = if is_super_admin && permissioned_as == email {
|
|
crate::usernames::get_instance_username_or_fallback_to_email(&mut *conn, email).await?
|
|
} else {
|
|
permissioned_as.to_string()
|
|
};
|
|
Ok(Authed {
|
|
email: email.to_string(),
|
|
username,
|
|
is_admin: is_super_admin,
|
|
is_operator: true,
|
|
groups: vec![],
|
|
folders: vec![],
|
|
scopes: None,
|
|
token_prefix: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub async fn get_folders_for_user<'e, E: sqlx::PgExecutor<'e>>(
|
|
w_id: &str,
|
|
username: &str,
|
|
groups: &[String],
|
|
db: E,
|
|
) -> Result<Vec<(String, bool, bool)>> {
|
|
let mut perms = groups
|
|
.into_iter()
|
|
.map(|x| format!("g/{}", x))
|
|
.collect::<Vec<_>>();
|
|
perms.insert(0, format!("u/{}", username));
|
|
let folders = sqlx::query!(
|
|
"SELECT name, (EXISTS (SELECT 1 FROM (SELECT key, value FROM jsonb_each_text(extra_perms) WHERE key = ANY($1)) t WHERE value::boolean IS true)) as write, $1 && owners::text[] as owner FROM folder
|
|
WHERE extra_perms ?| $1 AND workspace_id = $2",
|
|
&perms[..],
|
|
w_id,
|
|
)
|
|
.fetch_all(db)
|
|
.await?
|
|
.into_iter()
|
|
.map(|x| (x.name, x.write.unwrap_or(false), x.owner.unwrap_or(false)))
|
|
.collect();
|
|
|
|
Ok(folders)
|
|
}
|
|
|
|
pub async fn get_groups_for_user<'e, E: sqlx::PgExecutor<'e>>(
|
|
w_id: &str,
|
|
username: &str,
|
|
email: &str,
|
|
db: E,
|
|
) -> Result<Vec<String>> {
|
|
let groups = sqlx::query_scalar!(
|
|
"SELECT group_ FROM usr_to_group where usr = $1 AND workspace_id = $2 UNION ALL SELECT igroup FROM email_to_igroup WHERE email = $3",
|
|
username,
|
|
w_id,
|
|
email
|
|
)
|
|
.fetch_all(db)
|
|
.await?
|
|
.into_iter().filter_map(|x| x)
|
|
.collect();
|
|
Ok(groups)
|
|
}
|
|
|
|
pub async fn get_job_perms<'a, E: sqlx::PgExecutor<'a>>(
|
|
db: E,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
) -> sqlx::Result<Option<JobPerms>> {
|
|
sqlx::query_as!(
|
|
JobPerms,
|
|
"SELECT email, username, is_admin, is_operator, groups, folders, end_user_email FROM job_perms WHERE job_id = $1 AND workspace_id = $2",
|
|
job_id,
|
|
w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.warn_after_seconds(3)
|
|
.await
|
|
}
|
|
|
|
/// A job token is refreshed once its remaining lifetime drops below this. It must exceed the
|
|
/// 60s `jsonwebtoken` exp leeway, otherwise a token that still validates now could expire
|
|
/// mid-orchestration after being judged fresh.
|
|
pub const JOB_TOKEN_REFRESH_MARGIN_SECS: i64 = 120;
|
|
|
|
/// Seconds until an internal job JWT expires, or `None` when `token` is not a decodable job
|
|
/// JWT (e.g. the empty test token). The signature is intentionally not verified: the value only
|
|
/// gates whether to refresh the token, never whose identity to assume.
|
|
pub fn job_token_remaining_lifetime_secs(token: &str) -> Option<i64> {
|
|
let raw = token.strip_prefix("jwt_")?;
|
|
let claims: JWTAuthClaims = jwt::decode_without_verify(raw).ok()?;
|
|
Some(claims.exp as i64 - Utc::now().timestamp())
|
|
}
|
|
|
|
/// Label for an ephemeral job token. For a job run on behalf of an end user (its
|
|
/// `permissioned_as` differs from its `created_by`) it encodes that end user so
|
|
/// `username_override_from_label` can recover them; otherwise it is the plain script label.
|
|
pub fn ephemeral_script_token_label(permissioned_as: &str, created_by: &str) -> String {
|
|
if permissioned_as != format!("u/{created_by}") && permissioned_as != created_by {
|
|
format!("ephemeral-script-end-user-{created_by}")
|
|
} else {
|
|
"ephemeral-script".to_string()
|
|
}
|
|
}
|
|
|
|
/// Lifetime to mint an ephemeral job token with, resolved per workspace: a token narrower than
|
|
/// the run it serves 401s the job mid-flight, and one wider is a credential nothing revokes.
|
|
pub async fn job_token_expiry_secs(_db: &DB, _w_id: &str) -> u64 {
|
|
if let Some(override_secs) = *crate::worker::SCRIPT_TOKEN_EXPIRY_OVERRIDE {
|
|
return override_secs;
|
|
}
|
|
#[cfg(feature = "cloud")]
|
|
let premium = *crate::worker::CLOUD_HOSTED
|
|
&& crate::workspaces::get_team_plan_status(_db, _w_id)
|
|
.await
|
|
.inspect_err(|err| {
|
|
tracing::error!(
|
|
"Failed to get team plan status to size the job token for {_w_id}: {err:#}"
|
|
)
|
|
})
|
|
.map(|s| s.premium)
|
|
// Matches resolve_job_timeout: on a lookup failure assume the wider ceiling, so the
|
|
// token cannot come out shorter than the timeout the job is actually held to.
|
|
.unwrap_or(true);
|
|
#[cfg(not(feature = "cloud"))]
|
|
let premium = false;
|
|
|
|
job_token_expiry_from_premium(premium)
|
|
}
|
|
|
|
/// Cap on the setup slack below. Where the ceiling is already days, slack buys nothing and only
|
|
/// widens the credential; an hour is what a dependency install realistically needs.
|
|
const JOB_TOKEN_SETUP_SLACK_CAP_SECS: u64 = 3600;
|
|
|
|
fn job_token_expiry_from_premium(cloud_premium_workspace: bool) -> u64 {
|
|
// The token's clock starts at pull, but resolve_job_timeout runs per handle_child, so setup
|
|
// (lock, install, bundle) spends a budget of its own before the run phase gets one. Slack
|
|
// covers the realistic case; pull-to-finish is not bounded by the ceiling at all.
|
|
let max_run = crate::worker::max_job_duration_secs(cloud_premium_workspace);
|
|
max_run
|
|
.saturating_add(max_run.min(JOB_TOKEN_SETUP_SLACK_CAP_SECS))
|
|
// create_jwt_token casts this to i64; a saturated value there mints an expired token.
|
|
.min(u32::MAX as u64)
|
|
}
|
|
|
|
#[tracing::instrument(level = "trace", skip_all)]
|
|
pub async fn create_token_for_owner(
|
|
db: &DB,
|
|
w_id: &str,
|
|
owner: &str,
|
|
label: &str,
|
|
expires_in: u64,
|
|
email: &str,
|
|
job_id: &Uuid,
|
|
perms: Option<JobPerms>,
|
|
audit_span: Option<String>,
|
|
) -> crate::error::Result<String> {
|
|
let job_perms = if perms.is_some() {
|
|
Ok(perms)
|
|
} else {
|
|
get_job_perms(db, job_id, w_id).await
|
|
};
|
|
let job_authed = match job_perms {
|
|
Ok(Some(jp)) => jp.into(),
|
|
_ => {
|
|
tracing::warn!("Could not get permissions for job {job_id} from job_perms table, getting permissions directly...");
|
|
fetch_authed_from_permissioned_as(owner, email, w_id, db)
|
|
.await
|
|
.map_err(|e| {
|
|
Error::internal_err(format!(
|
|
"Could not get permissions directly for job {job_id}: {e:#}"
|
|
))
|
|
})?
|
|
}
|
|
};
|
|
|
|
create_jwt_token(
|
|
job_authed,
|
|
w_id,
|
|
expires_in,
|
|
Some(*job_id),
|
|
Some(label.to_string()),
|
|
audit_span,
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn create_jwt_token(
|
|
authed: Authed,
|
|
workspace_id: &str,
|
|
expires_in_seconds: u64,
|
|
job_id: Option<Uuid>,
|
|
label: Option<String>,
|
|
audit_span: Option<String>,
|
|
scopes: Option<Vec<String>>,
|
|
) -> crate::error::Result<String> {
|
|
let payload = JWTAuthClaims {
|
|
email: authed.email.clone(),
|
|
username: authed.username.clone(),
|
|
is_admin: authed.is_admin,
|
|
is_operator: authed.is_operator,
|
|
groups: authed.groups.clone(),
|
|
folders: authed.folders.clone(),
|
|
label,
|
|
workspace_id: Some(workspace_id.to_string()),
|
|
workspace_ids: None,
|
|
exp: (chrono::Utc::now() + chrono::Duration::seconds(expires_in_seconds as i64)).timestamp()
|
|
as usize,
|
|
job_id: job_id.map(|id| id.to_string()),
|
|
scopes,
|
|
audit_span,
|
|
};
|
|
|
|
let token = jwt::encode_with_internal_secret(&payload)
|
|
.await
|
|
.with_context(|| match job_id {
|
|
Some(job_id) => format!("Could not encode JWT token for job {job_id}"),
|
|
None => "Could not encode JWT token".to_string(),
|
|
})?;
|
|
|
|
Ok(format!("jwt_{}", token))
|
|
}
|
|
|
|
#[cfg(feature = "aws_auth")]
|
|
pub mod aws {
|
|
|
|
use super::*;
|
|
use crate::utils::empty_as_none;
|
|
use aws_config::{BehaviorVersion, Region};
|
|
use aws_sdk_sts::{
|
|
config::Credentials as AwsCredentials,
|
|
operation::{
|
|
assume_role_with_saml::AssumeRoleWithSamlOutput,
|
|
assume_role_with_web_identity::{
|
|
builders::AssumeRoleWithWebIdentityFluentBuilder, AssumeRoleWithWebIdentityOutput,
|
|
},
|
|
},
|
|
types::Credentials,
|
|
Client,
|
|
};
|
|
|
|
pub const AWS_OIDC_AUDIENCE: &'static str = "sts.amazonaws.com";
|
|
|
|
pub trait GetAuthenticationOutput {
|
|
fn get_credentials(&self) -> Result<&Credentials>;
|
|
}
|
|
|
|
impl GetAuthenticationOutput for AssumeRoleWithSamlOutput {
|
|
fn get_credentials(&self) -> Result<&Credentials> {
|
|
let credentials = self.credentials.as_ref().ok_or(Error::BadGateway(
|
|
"Error fetching credentials from AWS STS".to_string(),
|
|
))?;
|
|
Ok(credentials)
|
|
}
|
|
}
|
|
|
|
impl GetAuthenticationOutput for AssumeRoleWithWebIdentityOutput {
|
|
fn get_credentials(&self) -> Result<&Credentials> {
|
|
let credentials = self.credentials.as_ref().ok_or(Error::BadGateway(
|
|
"Error fetching credentials from AWS STS".to_string(),
|
|
))?;
|
|
Ok(credentials)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
|
|
#[sqlx(type_name = "AWS_AUTH_RESOURCE_TYPE", rename_all = "lowercase")]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AwsAuthResourceType {
|
|
Credentials,
|
|
Oidc,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CredentialsAuth {
|
|
#[serde(deserialize_with = "empty_as_none")]
|
|
pub region: Option<String>,
|
|
#[serde(rename = "awsAccessKeyId")]
|
|
pub aws_access_key_id: String,
|
|
#[serde(rename = "awsSecretAccessKey")]
|
|
pub aws_secret_access_key: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub struct OidcAuth {
|
|
#[serde(deserialize_with = "empty_as_none")]
|
|
pub region: Option<String>,
|
|
#[serde(rename = "roleArn")]
|
|
pub role_arn: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum AWSAuthConfig {
|
|
Credentials(CredentialsAuth),
|
|
Oidc(OidcAuth),
|
|
}
|
|
|
|
pub async fn get_assume_role_with_web_identity_fluent_builder(
|
|
oidc_auth: &OidcAuth,
|
|
token: String,
|
|
role_session_name: Option<impl ToString>,
|
|
) -> Result<AssumeRoleWithWebIdentityFluentBuilder> {
|
|
let region = oidc_auth.region.as_deref().unwrap_or_else(|| "us-east-1");
|
|
|
|
let credentials = AwsCredentials::new("", "", None, None, "UserInput");
|
|
|
|
let config = aws_config::defaults(BehaviorVersion::latest())
|
|
.credentials_provider(credentials)
|
|
.region(Region::new(region.to_string()))
|
|
.load()
|
|
.await;
|
|
|
|
let assume_role_with_web_identity_fluent_builder = Client::new(&config)
|
|
.assume_role_with_web_identity()
|
|
.set_role_arn(Some(oidc_auth.role_arn.to_owned()))
|
|
.set_role_session_name(role_session_name.map(|str| str.to_string()))
|
|
.set_web_identity_token(Some(token));
|
|
|
|
Ok(assume_role_with_web_identity_fluent_builder)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{is_reserved_on_behalf_of_identity, is_user_token};
|
|
use super::{job_token_expiry_from_premium, job_token_remaining_lifetime_secs};
|
|
use super::{JWTAuthClaims, JOB_TOKEN_REFRESH_MARGIN_SECS};
|
|
use crate::users::{
|
|
SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL,
|
|
};
|
|
use crate::worker::max_job_duration_secs;
|
|
|
|
fn job_jwt(exp_offset_secs: i64) -> String {
|
|
let claims = JWTAuthClaims {
|
|
email: String::new(),
|
|
username: String::new(),
|
|
is_admin: false,
|
|
is_operator: false,
|
|
groups: vec![],
|
|
folders: vec![],
|
|
label: None,
|
|
workspace_id: None,
|
|
workspace_ids: None,
|
|
exp: (chrono::Utc::now().timestamp() + exp_offset_secs) as usize,
|
|
job_id: None,
|
|
scopes: None,
|
|
audit_span: None,
|
|
};
|
|
// Signature is irrelevant — the gate decodes without verifying — so any key works.
|
|
let token = jsonwebtoken::encode(
|
|
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
|
|
&claims,
|
|
&jsonwebtoken::EncodingKey::from_secret(b"test"),
|
|
)
|
|
.unwrap();
|
|
format!("jwt_{token}")
|
|
}
|
|
|
|
#[test]
|
|
fn remaining_lifetime_reflects_exp_and_flags_near_expiry() {
|
|
// A token minted for less than the margin reads as needing a refresh...
|
|
let short = job_token_remaining_lifetime_secs(&job_jwt(30)).unwrap();
|
|
assert!(short < JOB_TOKEN_REFRESH_MARGIN_SECS);
|
|
// ...a long-lived one does not...
|
|
let long = job_token_remaining_lifetime_secs(&job_jwt(10_000)).unwrap();
|
|
assert!(long >= JOB_TOKEN_REFRESH_MARGIN_SECS);
|
|
// ...and a non-JWT token (e.g. the empty test-run token) yields no lifetime.
|
|
assert!(job_token_remaining_lifetime_secs("not-a-jwt").is_none());
|
|
assert!(job_token_remaining_lifetime_secs("").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn reserved_on_behalf_of_identity_matches_every_sentinel_in_either_field() {
|
|
// Matched on the email (secret / notification) or on permissioned_as (sync).
|
|
assert!(is_reserved_on_behalf_of_identity(
|
|
None,
|
|
Some(SUPERADMIN_SECRET_EMAIL)
|
|
));
|
|
assert!(is_reserved_on_behalf_of_identity(
|
|
None,
|
|
Some(SUPERADMIN_NOTIFICATION_EMAIL)
|
|
));
|
|
assert!(is_reserved_on_behalf_of_identity(
|
|
Some(SUPERADMIN_SYNC_EMAIL),
|
|
None
|
|
));
|
|
// A sentinel smuggled as a raw-email permissioned_as (schedules/triggers
|
|
// derive the email from it) is caught too.
|
|
assert!(is_reserved_on_behalf_of_identity(
|
|
Some(SUPERADMIN_SECRET_EMAIL),
|
|
None
|
|
));
|
|
// Ordinary identities pass.
|
|
assert!(!is_reserved_on_behalf_of_identity(None, None));
|
|
assert!(!is_reserved_on_behalf_of_identity(
|
|
Some("u/alice"),
|
|
Some("alice@example.com")
|
|
));
|
|
assert!(!is_reserved_on_behalf_of_identity(Some("g/team"), None));
|
|
}
|
|
|
|
#[test]
|
|
fn user_tokens_are_editable() {
|
|
assert!(is_user_token(None)); // no label
|
|
assert!(is_user_token(Some("")));
|
|
assert!(is_user_token(Some("my-ci-token")));
|
|
assert!(is_user_token(Some("webhook-foo"))); // username-override prefix, not a system kind here
|
|
}
|
|
|
|
#[test]
|
|
fn system_tokens_are_not_editable() {
|
|
assert!(!is_user_token(Some("session")));
|
|
assert!(!is_user_token(Some("ephemeral-script")));
|
|
assert!(!is_user_token(Some("ephemeral-webhook-x")));
|
|
assert!(!is_user_token(Some("Ephemeral lsp token")));
|
|
assert!(!is_user_token(Some("debugger-token")));
|
|
assert!(!is_user_token(Some("mcp-oauth-client")));
|
|
assert!(!is_user_token(Some("embed_app:f/team/dashboard")));
|
|
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
|
|
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
|
|
assert!(!is_user_token(Some("cli-login:admin")));
|
|
}
|
|
|
|
#[test]
|
|
fn ephemeral_match_is_case_insensitive() {
|
|
// Must agree with the frontend mirror (`toLowerCase().startsWith('ephemeral')`)
|
|
// so a token can't be relabeled to a casing the backend allows but the UI hides.
|
|
assert!(!is_user_token(Some("Ephemeral-test")));
|
|
assert!(!is_user_token(Some("ePhemeral-test")));
|
|
assert!(!is_user_token(Some("EPHEMERAL-test")));
|
|
}
|
|
|
|
#[test]
|
|
fn job_token_outlives_the_longest_job_it_may_serve() {
|
|
for premium in [false, true] {
|
|
assert!(
|
|
job_token_expiry_from_premium(premium) > max_job_duration_secs(premium),
|
|
"token expiry must exceed the job ceiling (premium: {premium})"
|
|
);
|
|
}
|
|
}
|
|
}
|