fix: keep fork out of is_restricted and read it from the caller's role

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-22 07:36:36 -07:00
co-authored by Claude Opus 5.5
parent 8bba9e92f9
commit 304279f73e
5 changed files with 32 additions and 12 deletions
@@ -9013,7 +9013,7 @@ async fn check_can_fork(db: &DB, authed: &ApiAuthed, w_id: &str) -> Result<()> {
if *DISABLE_WORKSPACE_FORK {
require_super_admin(db, authed).await?;
}
if !windmill_common::roles::caller_capabilities(db, w_id, authed)
if !windmill_common::roles::caller_role_capabilities(db, w_id, authed)
.await?
.fork
{
+25 -4
View File
@@ -143,11 +143,13 @@ impl RoleCapabilities {
}
/// Whether a member of this role is gated at all. Every gate skips an unrestricted caller
/// without reading its role, which is what `ApiAuthed::is_operator` carries. Visibility is
/// left out: it only hides pages, and must not turn a full author into an operator for the
/// raw `is_operator` checks, job permissions and credential caches keyed on it.
/// without reading its role, which is what `ApiAuthed::is_operator` carries. Visibility and
/// `fork` are left out: neither may turn a full author into an operator for the raw
/// `is_operator` checks, job permissions and credential caches keyed on it. A role defined
/// before `fork` existed reads it as unset, so it would otherwise turn restricted on upgrade.
/// `fork` is checked with [`caller_role_capabilities`] instead.
pub fn is_restricted(&self) -> bool {
Self { visibility: RoleVisibility::ALL, ..*self } != Self::FULL
Self { visibility: RoleVisibility::ALL, fork: true, ..*self } != Self::FULL
}
/// Whether the role authors deployable artifacts and so costs a full seat rather than half.
@@ -714,6 +716,25 @@ async fn member_role(db: &DB, workspace_id: &str, username: &str) -> Result<Opti
Ok(role)
}
/// The caller's role capabilities in `workspace_id`, read even for an unrestricted caller, whom
/// [`caller_capabilities`] answers with `FULL` unread. Needed for a capability `is_restricted`
/// leaves out, such as `fork`. An admin holds everything; an unrestricted caller without a
/// membership row keeps `FULL`, a restricted one gets none.
pub async fn caller_role_capabilities<A: Authable + ?Sized>(
db: &DB,
workspace_id: &str,
authed: &A,
) -> Result<RoleCapabilities> {
if authed.is_admin() {
return Ok(RoleCapabilities::FULL);
}
match member_role(db, workspace_id, authed.username()).await? {
Some(role) => role_capabilities_by_name(db, workspace_id, &role).await,
None if authed.is_operator() => Ok(RoleCapabilities::NONE),
None => Ok(RoleCapabilities::FULL),
}
}
/// What the caller may do in `workspace_id`. An admin, and any caller authentication did not mark
/// restricted, holds everything; a restricted caller holds its role's capabilities, or none if it
/// has no membership (a guest, a removed member's job). `workspace_id` MUST be the route's.
+1 -1
View File
@@ -19,7 +19,7 @@ code is `windmill_common::roles`.
| `flows` | `none` / `compose` / `full` | yes |
| `apps` | `none` / `compose` / `full` | yes; low-code apps and worker-built apps need `full` |
| `manage_schedules`, `manage_triggers` | bool | yes, see `docs/operator-write-rights.md` |
| `fork` | bool | yes: both fork endpoints; the forker is a developer in the fork, see `docs/operator-write-rights.md` |
| `fork` | bool | yes: both fork endpoints, read with `caller_role_capabilities`; the forker is a developer in the fork, see `docs/operator-write-rights.md`. Like visibility, not part of `is_restricted` |
| `visibility.*` | bool per page | no, UI only |
`compose` is the former operator builder right: the composition-only checks of
@@ -58,7 +58,7 @@
function overriddenKeys(o: RoleOverride | undefined): string[] {
const c = o?.capabilities ?? {}
const keys = ['code', 'flows', 'apps', 'manage_schedules', 'manage_triggers'].filter(
const keys = ['code', 'flows', 'apps', 'manage_schedules', 'manage_triggers', 'fork'].filter(
(k) => c[k] != null
)
for (const { key } of VISIBILITY_KEYS) {
+4 -5
View File
@@ -56,17 +56,16 @@ export function capabilitiesIn(
return operator ? legacyOperatorCapabilities(workspace?.operator_settings) : FULL_CAPABILITIES
}
/** Whether the role is gated at all, as `RoleCapabilities::is_restricted`: visibility is left out,
* since hiding a page must not cost a full author what restricted roles are refused, such as
* forking. */
/** Whether the role is gated at all, as `RoleCapabilities::is_restricted`: visibility and `fork`
* are left out, since neither must cost a full author what restricted roles are refused. Gate
* forking on `caps.fork` itself. */
export function isRestricted(caps: RoleCapabilities): boolean {
return (
!caps.code ||
caps.flows !== 'full' ||
caps.apps !== 'full' ||
!caps.manage_schedules ||
!caps.manage_triggers ||
!caps.fork
!caps.manage_triggers
)
}