feat: configurable workspace roles with per-workspace overrides (#11821)

* feat: configurable workspace roles with per-workspace overrides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: close role review gaps in pickers, declarative sync and removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: keep resolve_role_name doc on its function

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: validate overrides on instance role changes and bill admins fully

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: allow pipeline drafts and keep role checks on one connection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: list data pipelines in operator_can_draft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: expect a 403 role refusal when an operator resolves jobs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: require a valid license for custom workspace roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: freeze workspace roles when the license lapses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee companion for workspace roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: broadcast role override changes from the write path, not a trigger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to b5294d38db082bdd104d5358a47695390694c05d

This commit updates the EE repository reference after PR #891 was merged in windmill-ee-private.

Previous ee-repo-ref: 688e00c5982865b14098f530d54280e2311994c0

New ee-repo-ref: b5294d38db082bdd104d5358a47695390694c05d

Automated by sync-ee-ref workflow.

* fix: let an admin row that kept the operator flag hold full rights everywhere

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: treat superadmin members as admins and return role names from get user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-21 15:51:54 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent c740a8f369
commit 21d2a49971
106 changed files with 3966 additions and 822 deletions
+156 -134
View File
@@ -192,7 +192,11 @@ pub fn workspaced_service() -> Router {
.route("/usage", get(get_usage))
.route("/used_triggers", get(get_used_triggers))
.route("/public_app_rate_limit", post(edit_public_app_rate_limit))
.route("/operator_settings", post(update_operator_settings))
.route(
"/operator_settings",
post(crate::workspace_roles::update_operator_settings),
)
.merge(crate::workspace_roles::workspaced_service())
.route(
"/create_workspace_fork_branch",
post(create_workspace_fork_branch),
@@ -442,6 +446,8 @@ struct RunSlackMessageTestJobResponse {
#[derive(Deserialize)]
pub struct EditAutoInvite {
pub operator: Option<bool>,
/// The role auto-invited members get; wins over `operator`.
pub role: Option<String>,
pub invite_all: Option<bool>,
pub auto_add: Option<bool>,
}
@@ -579,6 +585,9 @@ struct UserWorkspace {
pub created_by: Option<String>,
pub disabled: bool,
pub is_service_account: bool,
/// The member's role in this workspace.
pub role: String,
pub capabilities: windmill_common::roles::RoleCapabilities,
}
#[derive(Deserialize)]
@@ -596,7 +605,11 @@ struct ValidateUsername {
pub struct NewWorkspaceInvite {
pub email: String,
pub is_admin: bool,
/// Legacy role selector, `true` for the `operator` role; `role` wins.
#[serde(default)]
pub operator: bool,
#[serde(default)]
pub role: Option<String>,
}
#[derive(Deserialize)]
@@ -604,7 +617,34 @@ pub struct NewWorkspaceUser {
pub email: String,
pub username: Option<String>,
pub is_admin: bool,
#[serde(default)]
pub operator: bool,
#[serde(default)]
pub role: Option<String>,
}
/// The role a new member or invite gets, `role` if given, else the one the legacy `operator` flag
/// names, and the `usr.operator` value that goes with it: "cannot write code".
async fn resolve_new_member_role(
db: &DB,
w_id: &str,
operator: bool,
role: Option<&str>,
) -> Result<(String, bool)> {
let roles = windmill_common::roles::workspace_roles(db, w_id).await?;
let name = match role {
Some(role) => role.to_string(),
None if operator => windmill_common::roles::OPERATOR_ROLE.to_string(),
None => windmill_common::roles::default_role_name(roles.iter().map(|r| r.name.as_str())),
};
let caps = roles
.iter()
.find(|r| r.name == name)
.ok_or_else(|| {
Error::BadRequest(format!("role `{name}` is not defined in workspace {w_id}"))
})?
.capabilities;
Ok((name, !caps.code))
}
/// The role is one choice stored as two flags; both set would show as admin in the UI while the
@@ -718,6 +758,7 @@ async fn list_pending_invites(
workspace_invite.email,
workspace_invite.is_admin,
workspace_invite.operator,
COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\",
workspace.parent_workspace_id
FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id
WHERE workspace_id = $1
@@ -6689,13 +6730,11 @@ async fn user_workspaces(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
) -> JsonResult<WorkspaceList> {
let mut tx = db.begin().await?;
let workspaces = sqlx::query_as!(
UserWorkspace,
let rows = sqlx::query!(
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,
workspace.is_dev_workspace, workspace.dev_workspace_label,
workspace.owner AS \"created_by?\",
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,
usr.is_admin, usr.operator, usr.workspace_role,
usr.disabled, usr.is_service_account
FROM workspace
JOIN usr ON usr.workspace_id = workspace.id
@@ -6703,9 +6742,46 @@ async fn user_workspaces(
WHERE usr.email = $1 AND workspace.deleted = false",
email
)
.fetch_all(&mut *tx)
.fetch_all(&db)
.await?;
tx.commit().await?;
let mut workspaces = Vec::with_capacity(rows.len());
for r in rows {
let role = windmill_common::roles::resolve_role_name(r.workspace_role, r.operator);
let capabilities = if r.is_admin {
windmill_common::roles::RoleCapabilities::FULL
} else {
windmill_common::roles::role_capabilities_by_name(&db, &r.id, &role).await?
};
// The pre-roles shape the frontend gates on: present exactly for a restricted member.
let operator_settings = (capabilities.is_restricted()).then(|| {
let v = &capabilities.visibility;
Some(serde_json::json!({
"runs": v.runs, "schedules": v.schedules, "resources": v.resources,
"variables": v.variables, "assets": v.assets, "triggers": v.triggers,
"audit_logs": v.audit_logs, "groups": v.groups, "folders": v.folders,
"workers": v.workers,
"builder_flows": capabilities.flows != windmill_common::roles::AuthorLevel::None,
"builder_apps": capabilities.apps != windmill_common::roles::AuthorLevel::None,
"manage_schedules": capabilities.manage_schedules,
"manage_triggers": capabilities.manage_triggers,
}))
});
workspaces.push(UserWorkspace {
id: r.id,
name: r.name,
username: r.username,
color: r.color,
operator_settings,
parent_workspace_id: r.parent_workspace_id,
is_dev_workspace: r.is_dev_workspace,
dev_workspace_label: r.dev_workspace_label,
created_by: r.created_by,
disabled: r.disabled,
is_service_account: r.is_service_account,
role,
capabilities,
});
}
Ok(Json(WorkspaceList { email, workspaces }))
}
@@ -7672,9 +7748,9 @@ async fn copy_workspace_members(
// instance-group config, so a copied `instance_group` provenance would let the fork's
// reconciliation delete them and their data.
sqlx::query!(
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via, workspace_role)
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,
CASE WHEN $3 THEN NULL ELSE added_via END
CASE WHEN $3 THEN NULL ELSE added_via END, workspace_role
FROM usr WHERE workspace_id = $2
AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))
ON CONFLICT DO NOTHING",
@@ -7684,6 +7760,27 @@ async fn copy_workspace_members(
)
.execute(&mut **tx)
.await?;
// The members keep their roles, so the fork needs the source's workspace-only roles and
// overrides to give them the same meaning.
sqlx::query!(
"UPDATE workspace_settings SET role_overrides =
(SELECT role_overrides FROM workspace_settings WHERE workspace_id = $2)
WHERE workspace_id = $1",
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"INSERT INTO role_seat (workspace_id, role, author)
SELECT $1, role, author FROM role_seat WHERE workspace_id = $2
ON CONFLICT DO NOTHING",
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
windmill_common::roles::invalidate_workspace_roles_cache(target_workspace_id);
Ok(())
}
@@ -11389,7 +11486,11 @@ async fn invite_user(
Json(mut nu): Json<NewWorkspaceInvite>,
) -> Result<(StatusCode, String)> {
require_admin(is_admin, &username)?;
reject_admin_and_operator(nu.is_admin, nu.operator)?;
if nu.role.is_none() {
reject_admin_and_operator(nu.is_admin, nu.operator)?;
}
let (role, role_operator) =
resolve_new_member_role(&db, &w_id, nu.operator, nu.role.as_deref()).await?;
#[cfg(not(feature = "enterprise"))]
if w_id == "admins" {
@@ -11404,7 +11505,7 @@ async fn invite_user(
if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user(
&db,
&nu.email,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.is_admin, &role).await?,
)
.await?
{
@@ -11431,13 +11532,15 @@ async fn invite_user(
sqlx::query!(
"INSERT INTO workspace_invite
(workspace_id, email, is_admin, operator)
VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, email)
DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator",
(workspace_id, email, is_admin, operator, workspace_role)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, email)
DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator,
workspace_role = EXCLUDED.workspace_role",
&w_id,
nu.email,
nu.is_admin,
nu.operator
role_operator,
&role
)
.execute(&mut *tx)
.await?;
@@ -11490,7 +11593,13 @@ async fn authorize_fork_owner_add_user(
.await?
.ok_or_else(|| Error::RequireAdmin(authed.username.clone()))?;
if nu.is_admin || nu.operator {
if nu.is_admin
|| nu.operator
|| nu
.role
.as_deref()
.is_some_and(|r| r != windmill_common::roles::DEVELOPER_ROLE)
{
return Err(Error::PermissionDenied(format!(
"as the creator of fork {w_id} you can only add members as developers; ask an admin of \
{w_id} for any other role"
@@ -11541,7 +11650,11 @@ async fn add_user(
Path(w_id): Path<String>,
Json(mut nu): Json<NewWorkspaceUser>,
) -> Result<(StatusCode, String)> {
reject_admin_and_operator(nu.is_admin, nu.operator)?;
if nu.role.is_none() {
reject_admin_and_operator(nu.is_admin, nu.operator)?;
}
let (role, role_operator) =
resolve_new_member_role(&db, &w_id, nu.operator, nu.role.as_deref()).await?;
#[cfg(not(feature = "enterprise"))]
if w_id == "admins" {
@@ -11562,7 +11675,7 @@ async fn add_user(
if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user(
&db,
&nu.email,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.is_admin, &role).await?,
)
.await?
{
@@ -11622,13 +11735,14 @@ async fn add_user(
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin, operator)
VALUES ($1, $2, $3, $4, $5)",
(workspace_id, email, username, is_admin, operator, workspace_role)
VALUES ($1, $2, $3, $4, $5, $6)",
&w_id,
nu.email,
username,
nu.is_admin,
nu.operator
role_operator,
&role
)
.execute(&mut *tx)
.await?;
@@ -11737,11 +11851,14 @@ async fn delete_invite(
sqlx::query!(
"DELETE FROM workspace_invite WHERE
workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4",
workspace_id = $1 AND email = $2 AND is_admin = $3
AND ($5::text IS NOT NULL OR operator = $4)
AND ($5::text IS NULL OR $5 = COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END))",
&w_id,
nu.email,
nu.is_admin,
nu.operator
nu.operator,
nu.role
)
.execute(&mut *tx)
.await?;
@@ -12105,114 +12222,6 @@ pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result<Option<i32
Ok(rate_limit)
}
#[derive(Deserialize, Serialize)]
struct ChangeOperatorSettings {
#[serde(default)]
runs: bool,
#[serde(default)]
schedules: bool,
#[serde(default)]
resources: bool,
#[serde(default)]
variables: bool,
#[serde(default)]
assets: bool,
#[serde(default)]
triggers: bool,
#[serde(default)]
audit_logs: bool,
#[serde(default)]
groups: bool,
#[serde(default)]
folders: bool,
#[serde(default)]
workers: bool,
/// Write rights, so `None` (key absent) must mean "leave as stored" rather than a value: the
/// row is merged, not overwritten, and this endpoint takes whole-object payloads from git-sync
/// files that predate the key. Defaulting either way here would make an older file silently
/// withdraw or grant the right on every push.
///
/// `builder_flows` lets every operator of this workspace compose flows out of already-deployed
/// runnables, `builder_apps` full-code apps. Either makes each operator consume a full author
/// seat instead of half of one.
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_flows: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_apps: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_schedules: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_triggers: Option<bool>,
}
async fn update_operator_settings(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(db): Extension<DB>,
Json(settings): Json<ChangeOperatorSettings>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
// Every operator of the workspace turns into a full seat, which an offline license may not
// cover. Either right costs the same seat, so this prices "any", and it is a no-op delta when
// one of them is already on.
#[cfg(feature = "enterprise")]
if settings.builder_flows == Some(true) || settings.builder_apps == Some(true) {
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_operator_builder(&db, &w_id).await?
{
return Err(Error::BadRequest(msg));
}
}
let mut tx = db.begin().await?;
let settings_json = serde_json::json!(settings);
// Merged for the `Option` fields above; the visibility flags always serialize, so for them
// this is a plain overwrite.
sqlx::query!(
"UPDATE workspace_settings
SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1
WHERE workspace_id = $2",
settings_json,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.update_operator_settings",
ActionKind::Update,
&w_id,
None,
Some([("operator_settings", settings_json.to_string().as_str())].into()),
)
.await?;
tx.commit().await?;
windmill_common::workspaces::invalidate_operator_rights_cache(&w_id);
// Trigger git sync for operator settings changes
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::Settings {
setting_type: "operator_settings".to_string(),
},
Some("Operator settings updated".to_string()),
false,
None,
)
.await?;
Ok("Operator settings updated successfully".to_string())
}
// Protection Rules API endpoints
#[derive(Deserialize)]
@@ -13812,7 +13821,7 @@ async fn load_workspace_authed(
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, base_authed).await?;
let user_row = sqlx::query!(
"SELECT username, is_admin, operator FROM usr
"SELECT username, is_admin, operator, workspace_role FROM usr
WHERE workspace_id = $1 AND email = $2 AND disabled = false",
workspace_id,
&base_authed.email
@@ -13858,7 +13867,14 @@ async fn load_workspace_authed(
email: base_authed.email.clone(),
username: user_row.username,
is_admin: is_super_admin || user_row.is_admin,
is_operator: user_row.operator,
is_operator: !user_row.is_admin
&& windmill_common::roles::member_is_restricted(
&mut conn,
workspace_id,
user_row.workspace_role.clone(),
user_row.operator,
)
.await?,
groups,
folders,
scopes: base_authed.scopes.clone(),
@@ -15327,7 +15343,13 @@ pub async fn apply_settings_edit(
DefaultApp => edit_default_app(a, d, p, settings_edit_body(edit, body)?).await?,
Color => change_workspace_color(a, p, d, settings_edit_body(edit, body)?).await?,
OperatorSettings => {
update_operator_settings(a, p, d, settings_edit_body(edit, body)?).await?
crate::workspace_roles::update_operator_settings(
a,
p,
d,
settings_edit_body(edit, body)?,
)
.await?
}
Datatable => {
let Json(result) =