feat: add a Require isolated forks protection rule (#11834)

* feat: route the python SDK's S3 client settings through the S3 proxy

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

* fix: drop the boto3 config key and expose the proxied bucket to polars

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

* chore: update ee-repo-ref

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

* fix: proxy S3 settings only for server-signed tokens and keep tls verify

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

* chore: update ee-repo-ref

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

* feat: isolate fork object storage under a per-fork prefix with read-through

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

* fix: guard fork storage isolation against traversal, shared lakes and orphaning

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

* fix: serialize fork purges with same-id creation and guard shared middle forks

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

* fix: drop fork storage mappings on every replica and lock fork deletion against child creation

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

* chore: update ee-repo-ref

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

* chore: update ee-repo-ref

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

* chore: update ee-repo-ref

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

* wip: require isolated forks rule

* feat: add a Require isolated forks protection rule

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

* fix: recheck isolated forks under the datatable lock and judge bypass in the base

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

* chore: update ee-repo-ref to 455681e55eb7cdfcfdb7cd79cbae75b333e07943

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

Previous ee-repo-ref: 93f26e3cf816e4eb97b7d4dbbb5fc97fdf9821af

New ee-repo-ref: 455681e55eb7cdfcfdb7cd79cbae75b333e07943

Automated by sync-ee-ref workflow.

* fix: exempt only datatables the base does not own, compact fork data choices

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

* test: pin the isolated-forks owner check, fix its docs wording

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-16 22:40:21 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent 9375805fdf
commit ab4e790a21
14 changed files with 434 additions and 82 deletions
+109
View File
@@ -686,3 +686,112 @@ async fn test_protection_rule_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
/// `RequireIsolatedForks` refuses a non-bypassing forker any data shared with the workspace, on
/// both fork entry points, and lets an admin through.
#[sqlx::test(fixtures("base"))]
async fn test_require_isolated_forks(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_protection_rules_cache("test-workspace");
// `dt2` is a clone governed from `gov`, but its database is this workspace's; `dt3` points
// into `gov`'s database.
let pg = r#"{"resource_type": "postgresql", "resource_path": "u/test-user/pg"}"#;
let gov = r#"{"workspace_id": "gov", "datatable": "src"}"#;
sqlx::raw_sql(
"INSERT INTO workspace (id, name, owner) VALUES ('gov', 'gov', 'test-user');
INSERT INTO workspace_settings (workspace_id) VALUES ('gov')",
)
.execute(&db)
.await?;
for (w_id, datatables) in [
("gov", format!(r#"{{"src": {{"database": {pg}}}}}"#)),
(
"test-workspace",
format!(
r#"{{"dt1": {{"database": {pg}}}, "dt2": {{"database": {pg}, "governed_by": {gov}}},
"dt3": {{"reference": {gov}}}}}"#
),
),
] {
sqlx::query(
"UPDATE workspace_settings SET datatable = jsonb_build_object('datatables', $2::jsonb) \
WHERE workspace_id = $1",
)
.bind(w_id)
.bind(datatables)
.execute(&db)
.await?;
}
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let resp = authed(
client().post(format!("{base}/workspaces/protection_rules")),
"SECRET_TOKEN",
)
.json(&json!({
"name": "isolate",
"rules": ["RequireIsolatedForks"],
"bypass_users": [],
"bypass_groups": []
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let cloned =
json!([{"name": "dt1", "new_dbname": "wm_fork_iso__dt1", "fork_behavior": "schema_only"}]);
for (route, body, shared) in [
(
"create_fork",
json!({"shared_storages": ["_default_"], "forked_datatables": cloned}),
"storage",
),
(
"create_workspace_fork_branch",
json!({"shared_ducklakes": ["main"], "forked_datatables": cloned}),
"ducklake",
),
(
"create_fork",
json!({"forked_datatables": cloned}),
"data table `dt2`",
),
] {
let mut body = body;
body["id"] = json!("wm-fork-iso");
body["name"] = json!("iso");
let resp = authed(
client().post(format!("{base}/workspaces/{route}")),
"SECRET_TOKEN_2",
)
.json(&body)
.send()
.await?;
let status = resp.status();
let text = resp.text().await?;
assert!(
status == 403 && text.contains(shared) && !text.contains("dt3"),
"{route} sharing {shared} should be refused: {status} {text}"
);
}
// An admin bypasses the rule: what stops this request is the unknown storage name.
let resp = authed(
client().post(format!("{base}/workspaces/create_fork")),
"SECRET_TOKEN",
)
.json(&json!({"id": "wm-fork-iso", "name": "iso", "shared_storages": ["_default_"]}))
.send()
.await?;
let text = resp.text().await?;
assert!(
!text.contains("isolate"),
"admin should bypass the rule: {text}"
);
Ok(())
}
@@ -2332,6 +2332,9 @@ struct DataTableListItem {
/// at its parent. Its permissions apply here, and only its admins may edit them.
#[serde(skip_serializing_if = "Option::is_none")]
governing_workspace_id: Option<String>,
/// The workspace owning the database this entry points at, when it is not this workspace.
#[serde(skip_serializing_if = "Option::is_none")]
database_workspace_id: Option<String>,
/// Whether the governing entry is under roles.
permissioned: bool,
}
@@ -2361,6 +2364,8 @@ async fn list_datatables(
resource_path: database.resource_path.clone(),
governing_workspace_id: (governing.governing_workspace_id() != w_id)
.then(|| governing.governing_workspace_id().to_string()),
database_workspace_id: (governing.workspace_id != w_id)
.then(|| governing.workspace_id.clone()),
permissioned: governing.datatable.permissions.is_some(),
});
}
@@ -8897,6 +8902,7 @@ async fn create_workspace_fork_branch(
{
return Err(Error::PermissionDenied(msg));
}
enforce_isolated_forks(&db, &authed, &w_id, &nw).await?;
// Two-phase create for git-synced workspaces: this endpoint only creates the git branch(es) and
// validates up front; it does NOT create the workspace row. The caller follows up with
@@ -9647,6 +9653,7 @@ async fn create_workspace_fork(
{
return Err(Error::PermissionDenied(msg));
}
enforce_isolated_forks(&db, &authed, &parent_workspace_id, &nw).await?;
if nw.is_dev_workspace {
ensure_dev_parent_can_host_dev(&db, &parent_workspace_id).await?;
@@ -10021,6 +10028,94 @@ fn copy_requests(
.collect()
}
/// Under `RequireIsolatedForks` on `parent`, refuse a fork that would leave any of its data
/// shared: a shared storage or lake, or a data table not cloned (`Keep original` points the fork
/// at the parent's database).
async fn enforce_isolated_forks(
db: &DB,
authed: &ApiAuthed,
parent: &str,
nw: &CreateWorkspaceFork,
) -> Result<()> {
let Some(msg) = isolation_required(db, authed, parent).await? else {
return Ok(());
};
let mut shared: Vec<String> = nw
.shared_storages
.iter()
.map(|s| format!("storage `{s}`"))
.chain(
nw.shared_ducklakes
.iter()
.map(|l| format!("ducklake `{l}`")),
)
.collect();
shared.extend(
kept_parent_datatables(db, parent, &nw.forked_datatables)
.await?
.into_iter()
.map(|name| format!("data table `{name}`")),
);
refuse_shared(msg, shared)
}
/// The rule's message when `RequireIsolatedForks` on `parent` binds `authed`.
async fn isolation_required(db: &DB, authed: &ApiAuthed, parent: &str) -> Result<Option<String>> {
match check_user_against_rule(
parent,
&ProtectionRuleKind::RequireIsolatedForks,
AuditAuthorable::username(authed),
&authed.groups,
authed.is_admin,
db,
)
.await?
{
RuleCheckResult::Blocked(msg) => Ok(Some(msg)),
RuleCheckResult::Allowed => Ok(None),
}
}
/// `parent`'s own data tables that `forked` leaves as pointers at its database. An entry whose
/// database another workspace owns is not `parent`'s data, so the rule does not cover it.
async fn kept_parent_datatables(
db: &DB,
parent: &str,
forked: &[ForkedDatatableInfo],
) -> Result<Vec<String>> {
Ok(
windmill_common::workspaces::resolve_workspace_governing_datatables(db, parent)
.await?
.into_iter()
.filter(|(_, governing)| governing.workspace_id == parent)
.filter(|(name, _)| {
!forked.iter().any(|d| {
&d.name == name
&& matches!(
d.fork_behavior,
Some(
DataTableForkBehavior::SchemaOnly
| DataTableForkBehavior::SchemaAndData
)
)
})
})
.map(|(name, _)| name)
.collect(),
)
}
fn refuse_shared(msg: String, shared: Vec<String>) -> Result<()> {
if shared.is_empty() {
return Ok(());
}
Err(Error::PermissionDenied(format!(
"{msg}. Shared in this request: {}. Only a bypasser of the rule can fork while one of \
them cannot be isolated",
shared.join(", ")
)))
}
/// Record every storage of the fork's cloned settings as isolated, except `shared` ones.
/// Isolation is a per-fork-creation choice, never inherited: a fork of a shared fork is
/// isolated unless it asks otherwise.
@@ -10134,6 +10229,17 @@ async fn write_workspace_fork(
// Also before the external cluster's lifecycle lock, which finalizing an external copy takes:
// fork cleanup takes the two in this order.
windmill_common::workspaces::lock_fork_datatables(&mut tx, &parent_workspace_id).await?;
// Data table edits take the lock above: one committed since the creation check would reach
// this fork as a pointer at the parent's database.
if let Some(msg) = isolation_required(&db, &authed, &parent_workspace_id).await? {
let kept = kept_parent_datatables(&db, &parent_workspace_id, &nw.forked_datatables).await?;
refuse_shared(
msg,
kept.into_iter()
.map(|name| format!("data table `{name}`"))
.collect(),
)?;
}
if nw.is_dev_workspace {
// The checks above ran outside a transaction, so the parent's eligibility and the chain's
+4
View File
@@ -5640,6 +5640,9 @@ paths:
type: string
governing_workspace_id:
type: string
database_workspace_id:
type: string
description: The workspace owning the database this entry points at, when it is not this workspace.
permissioned:
type: boolean
@@ -39184,6 +39187,7 @@ components:
- RestrictAnonymousAppDeployment
- RestrictPublicRunSharing
- RestrictGuestAppDeployment
- RequireIsolatedForks
RuleBypasserGroups:
type: array
description: Groups that can bypass this ruleset
@@ -73,6 +73,7 @@ bitflags::bitflags! {
const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3;
const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4;
const RESTRICT_GUEST_APP_DEPLOYMENT = 1 << 5;
const REQUIRE_ISOLATED_FORKS = 1 << 6;
}
}
@@ -86,6 +87,7 @@ pub enum ProtectionRuleKind {
RestrictAnonymousAppDeployment,
RestrictPublicRunSharing,
RestrictGuestAppDeployment,
RequireIsolatedForks,
}
impl ProtectionRuleKind {
@@ -109,6 +111,7 @@ impl ProtectionRuleKind {
ProtectionRuleKind::RestrictGuestAppDeployment => {
ProtectionRules::RESTRICT_GUEST_APP_DEPLOYMENT
}
ProtectionRuleKind::RequireIsolatedForks => ProtectionRules::REQUIRE_ISOLATED_FORKS,
}
}
@@ -130,6 +133,9 @@ impl ProtectionRuleKind {
ProtectionRuleKind::RestrictGuestAppDeployment => {
"Opening an app to guests (anyone who can sign in) is restricted in this workspace"
}
ProtectionRuleKind::RequireIsolatedForks => {
"Forks of this workspace must isolate its data: no shared object storage or ducklake, and every data table cloned"
}
}
}
}
+1 -1
View File
@@ -8343,7 +8343,7 @@ write a secret you were not given: keep the placeholder.
| \`git_sync\` | \`{ repositories: [...] }\`, each with \`git_repo_resource_path\`, \`script_path\` and \`settings\` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |
| \`datatable\` | \`{ datatables: { <name>: { database: { resource_type, resource_path? } } } }\`. Role permissions are \`datatable_permissions\`. |
| \`datatable_permissions\` | \`{ <data table>: { default_role, roles: { <role>: [tenants] } } }\`: who may connect to each data table as which role. Tenants are \`u/<user>\`, \`g/<group>\`, \`f/<folder>\` or \`*\`; \`admin\` is always a role, and \`default_role: null\` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |
| \`protection_rules\` | \`{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| \`protection_rules\` | \`{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`, \`RequireIsolatedForks\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| \`ducklake\` | \`{ ducklakes: { <name>: { catalog, storage, ... } } }\`. |
| \`dbt_warehouses\` | dbt warehouse connections. |
| \`default_app\` | Path of the app shown as the workspace home. |
+12
View File
@@ -54,3 +54,15 @@ the fork does not own costs one HEAD per chain level before the GET.
them and refuses while they cannot be purged, since a same-id fork would read them.
- Isolation is a per-fork-creation choice (`shared_storages` on create-fork), never inherited.
Forks created before the registry existed have no rows and keep sharing.
## Requiring isolation
The `RequireIsolatedForks` protection rule on a workspace makes its forks leave nothing shared
with it, for every user the ruleset does not exempt (admins always are): no `shared_storages`,
no `shared_ducklakes`, and every data table whose database it owns cloned rather than kept as a
pointer. A data table whose database another workspace owns is left alone: that is not its data.
A clone governed from another workspace still has its own database, so it is not exempt. It is
checked against the workspace being forked, in
`create_workspace_fork` and in the git-branch pre-check (so a refused fork leaves no branch
behind), and the data table part again under the parent's `lock_fork_datatables`, which data table
edits also take: a table added between the two would otherwise reach the fork as a pointer.
@@ -36,10 +36,13 @@
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { isOperatorInWorkspace } from '$lib/components/sidebar/operatorRoutes'
import {
canUserBypassRuleKindInRulesets,
fetchProtectionRulesForWorkspace,
isRuleActiveInRulesets,
isRuleUnconditionallyActiveInRulesets
} from '$lib/workspaceProtectionRules.svelte'
import { resource } from 'runed'
import { getUserExt } from '$lib/user'
import { Button } from '$lib/components/common'
import {
DEV_WORKSPACE_LABELS,
@@ -279,6 +282,34 @@
let forkDatatableSection: ReturnType<typeof ForkDatatableSection> | undefined = $state(undefined)
let forkDucklakeSection: ReturnType<typeof ForkDucklakeSection> | undefined = $state(undefined)
let forkStorageSection: ReturnType<typeof ForkStorageSection> | undefined = $state(undefined)
let datatableCount = $state(0)
let ducklakeCount = $state(0)
let storageCount = $state(0)
let forkDataCount = $derived(datatableCount + ducklakeCount + storageCount)
// The base's `RequireIsolatedForks` rule leaves no data shared with it: the sections below drop
// their sharing options. The server enforces it; this only keeps the form from offering a
// choice it would refuse. Bypass is judged with the user's identity in the BASE, like
// ParentWorkspaceProtectionAlert.
const baseRuleContext = resource(
() => (isFork ? baseWorkspaceId : undefined),
async (ws) => {
if (!ws) return undefined
const [rules, user] = await Promise.all([
fetchProtectionRulesForWorkspace(ws),
getUserExt(ws)
])
return { rules, user }
}
)
let requireIsolation = $derived.by(() => {
const ctx = baseRuleContext.current
return (
!!ctx &&
isRuleActiveInRulesets(ctx.rules, 'RequireIsolatedForks') &&
!canUserBypassRuleKindInRulesets(ctx.rules, 'RequireIsolatedForks', ctx.user)
)
})
let workspaceColor: string | undefined = $state(undefined)
let colorEnabled = $state(false)
@@ -435,9 +466,11 @@
id: prefixed_id,
name,
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
// Sent in this first phase too, so a clone the fork would refuse is refused before
// any branch is created.
// Sent in this first phase too, so a clone or a sharing choice the fork would refuse
// is refused before any branch is created.
forked_datatables: forkedDatatables,
shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [],
shared_storages: forkStorageSection?.getSharedStorages() ?? [],
is_dev_workspace: createAsDevWorkspace,
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
// Send the lock intent in this first phase too so the backend can reject a non-admin's
@@ -1013,18 +1046,42 @@
{/if}
{/if}
{#if isFork}
<ForkDatatableSection
bind:this={forkDatatableSection}
sourceWorkspace={baseWorkspaceId}
onAllDone={() => {
completeFork(effectiveForkId)
}}
onCanceled={() => {
forkCreationLoading = false
}}
/>
<ForkDucklakeSection bind:this={forkDucklakeSection} sourceWorkspace={baseWorkspaceId} />
<ForkStorageSection bind:this={forkStorageSection} sourceWorkspace={baseWorkspaceId} />
<!-- Hidden rather than unmounted while empty: the sections load their own lists. -->
<div class={forkDataCount > 0 ? 'flex flex-col gap-3' : 'hidden'}>
<Label label="Fork data">
<span class="text-xs text-secondary">
Isolated or cloned: the fork writes only its own data, and this workspace's is never
changed. Shared: the fork reads and writes this workspace's data directly.
{#if requireIsolation}
A protection rule on this workspace requires isolation.
{/if}
</span>
</Label>
<ForkDatatableSection
bind:this={forkDatatableSection}
bind:itemCount={datatableCount}
sourceWorkspace={baseWorkspaceId}
{requireIsolation}
onAllDone={() => {
completeFork(effectiveForkId)
}}
onCanceled={() => {
forkCreationLoading = false
}}
/>
<ForkDucklakeSection
bind:this={forkDucklakeSection}
bind:itemCount={ducklakeCount}
sourceWorkspace={baseWorkspaceId}
{requireIsolation}
/>
<ForkStorageSection
bind:this={forkStorageSection}
bind:itemCount={storageCount}
sourceWorkspace={baseWorkspaceId}
{requireIsolation}
/>
</div>
{/if}
{#if !automateUsernameCreation}
<Label label="Your username in that workspace">
@@ -13,7 +13,7 @@
import { isCloudHosted } from '$lib/cloud'
import { resource } from 'runed'
import Select from '../select/Select.svelte'
import Label from '../Label.svelte'
import { TriangleAlert } from 'lucide-svelte'
import Alert from '../common/alert/Alert.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
@@ -21,11 +21,20 @@
// Workspace whose datatables are cloned into the fork (the fork's base). Falls back to the
// current workspace so existing callers keep working.
sourceWorkspace?: string
// A protection rule on the base forbids `keep_original` for the base's own datatables.
requireIsolation?: boolean
itemCount?: number
onAllDone?: () => void
onCanceled?: () => void
}
let { sourceWorkspace, onAllDone, onCanceled }: Props = $props()
let {
sourceWorkspace,
requireIsolation = false,
itemCount = $bindable(),
onAllDone,
onCanceled
}: Props = $props()
let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined)
@@ -34,8 +43,27 @@
async (ws) => (ws ? WorkspaceService.listDataTables({ workspace: ws }) : undefined)
)
let datatableBehaviors: Record<string, 'schema_only' | 'schema_and_data' | 'keep_original'> =
$state({})
$effect(() => {
itemCount = allDatatables.current?.length ?? 0
})
type Behavior = 'schema_only' | 'schema_and_data' | 'keep_original'
let datatableBehaviors: Record<string, Behavior> = $state({})
// An entry whose database another workspace owns is not the base's data: the rule leaves it
// alone, as the server does.
function mustClone(dt: { database_workspace_id?: string }): boolean {
return requireIsolation && !dt.database_workspace_id
}
function behaviorOf(dt: { name: string; database_workspace_id?: string }): Behavior {
const chosen = datatableBehaviors[dt.name] ?? 'keep_original'
return mustClone(dt) && chosen === 'keep_original' ? 'schema_only' : chosen
}
let anyShared = $derived(
(allDatatables.current ?? []).some((dt) => behaviorOf(dt) === 'keep_original')
)
let cloneModalOpen = $state(false)
let currentCloneJob: DatatableCloneJob | undefined = $state(undefined)
@@ -49,14 +77,11 @@
// A fork attempt starts here: what an earlier attempt confirmed is not this one's to send.
confirmedJobs = []
return (allDatatables.current ?? [])
.filter((dt) => {
const behavior = datatableBehaviors[dt.name] ?? 'keep_original'
return behavior !== 'keep_original'
})
.filter((dt) => behaviorOf(dt) !== 'keep_original')
.map((dt) => ({
name: dt.name,
resourceType: dt.resource_type,
behavior: datatableBehaviors[dt.name] as 'schema_only' | 'schema_and_data',
behavior: behaviorOf(dt) as 'schema_only' | 'schema_and_data',
// A dev workspace's id has no `wm-fork-` prefix; its copies are named like a fork's
_newDbName: `wm_fork_${targetWorkspaceId.replace(/^wm-fork-/, '').replace(/-/g, '_')}__${dt.name}`
}))
@@ -94,10 +119,10 @@
</script>
{#if allDatatables.current && allDatatables.current.length > 0}
<Label label="Data table behavior">
<span class="text-xs text-secondary"> Choose how to handle each datatable when forking </span>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-secondary">Data tables</span>
<div class="border rounded-md divide-y">
{#each allDatatables.current as dt}
{#each allDatatables.current as dt (dt.name)}
<div class="flex items-center gap-2 justify-between px-4 py-1.5">
<div class="flex flex-col">
<span class="text-xs font-medium">{dt.name}</span>
@@ -107,15 +132,18 @@
</div>
<Select
dropdownClass="max-w-96"
bind:value={
() => datatableBehaviors[dt.name] ?? 'keep_original',
(v) => (datatableBehaviors[dt.name] = v)
}
bind:value={() => behaviorOf(dt), (v) => (datatableBehaviors[dt.name] = v)}
items={[
{
value: 'keep_original',
label: dt.permissioned ? 'Keep original (under roles)' : 'Keep original'
},
...(mustClone(dt)
? []
: [
{
value: 'keep_original' as const,
label: dt.permissioned
? 'Shared with parent (under roles)'
: 'Shared with parent'
}
]),
// A copy of a data table under roles carries the source's owners and grants,
// which only the Enterprise Edition replays: elsewhere the server refuses it.
...(dt.permissioned && !$enterpriseLicense
@@ -131,7 +159,13 @@
</div>
{/each}
</div>
</Label>
{#if anyShared}
<span class="flex items-center gap-1 text-2xs text-amber-700 dark:text-amber-300">
<TriangleAlert size={12} class="shrink-0" />
Scripts in the fork will read and write this workspace's rows.
</span>
{/if}
</div>
{/if}
{#if cloneModalOpen && currentCloneJob}
@@ -3,13 +3,16 @@
import { workspaceStore } from '$lib/stores'
import { resource } from 'runed'
import Select from '../select/Select.svelte'
import Label from '../Label.svelte'
import { TriangleAlert } from 'lucide-svelte'
// The workspace the fork is created from (matches ForkDatatableSection): a
// fork-of-fork bases off the selected base, not the navigation workspace, so
// the lakes listed here must match the ones the create-fork call submits to.
let { sourceWorkspace }: { sourceWorkspace?: string } = $props()
let {
sourceWorkspace,
requireIsolation = false,
itemCount = $bindable()
}: { sourceWorkspace?: string; requireIsolation?: boolean; itemCount?: number } = $props()
let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined)
// Keyed on the source so a direct page load (store hydrates after mount) still fetches.
@@ -18,34 +21,42 @@
async (ws) => (ws ? WorkspaceService.listDucklakes({ workspace: ws }) : undefined)
)
$effect(() => {
itemCount = allDucklakes.current?.length ?? 0
})
let lakeBehaviors: Record<string, 'isolated' | 'shared'> = $state({})
// Lakes the user explicitly opted out of isolation for — sent as
// `shared_ducklakes` on the create-fork request. Unlisted lakes default to the
// isolated fork namespace with read-defer, so nothing needs sending for them.
export function getSharedDucklakes(): string[] {
if (requireIsolation) return []
return (allDucklakes.current ?? []).filter((name) => lakeBehaviors[name] === 'shared')
}
let anyShared = $derived((allDucklakes.current ?? []).some((n) => lakeBehaviors[n] === 'shared'))
let anyShared = $derived(
!requireIsolation && (allDucklakes.current ?? []).some((n) => lakeBehaviors[n] === 'shared')
)
</script>
<!-- Self-hides when the workspace has no ducklake configured, like the datatable section. -->
{#if allDucklakes.current && allDucklakes.current.length > 0}
<Label label="Ducklake data environment">
<span class="text-xs text-secondary">
By default each lake is isolated in the fork: materializations write a fork-scoped namespace
and reads of not-yet-materialized tables defer to this workspace's current data.
</span>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-secondary">Ducklakes</span>
<div class="border rounded-md divide-y">
{#each allDucklakes.current as name (name)}
<div class="flex items-center gap-2 justify-between px-4 py-1.5">
<span class="text-xs font-medium">{name}</span>
<Select
dropdownClass="max-w-96"
bind:value={() => lakeBehaviors[name] ?? 'isolated', (v) => (lakeBehaviors[name] = v)}
disabled={requireIsolation}
bind:value={
() => (requireIsolation ? 'isolated' : (lakeBehaviors[name] ?? 'isolated')),
(v) => (lakeBehaviors[name] = v)
}
items={[
{ value: 'isolated', label: 'Isolated (defer reads to parent)' },
{ value: 'isolated', label: 'Isolated' },
{ value: 'shared', label: 'Shared with parent' }
]}
/>
@@ -53,15 +64,10 @@
{/each}
</div>
{#if anyShared}
<div
class="flex items-start gap-2 px-3 py-2 text-xs rounded-md border border-amber-200 dark:border-amber-900/60 bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-300"
>
<TriangleAlert size={14} class="shrink-0 mt-0.5" />
<span>
A shared lake is NOT isolated: pipeline runs in the fork read and write this workspace's
tables directly.
</span>
</div>
<span class="flex items-center gap-1 text-2xs text-amber-700 dark:text-amber-300">
<TriangleAlert size={12} class="shrink-0" />
Pipeline runs in the fork will write this workspace's tables.
</span>
{/if}
</Label>
</div>
{/if}
@@ -3,12 +3,15 @@
import { workspaceStore } from '$lib/stores'
import { resource } from 'runed'
import Select from '../select/Select.svelte'
import Label from '../Label.svelte'
import { TriangleAlert } from 'lucide-svelte'
// The workspace the fork is created from, like ForkDucklakeSection: the storages
// listed here must match the ones the create-fork call submits to.
let { sourceWorkspace }: { sourceWorkspace?: string } = $props()
let {
sourceWorkspace,
requireIsolation = false,
itemCount = $bindable()
}: { sourceWorkspace?: string; requireIsolation?: boolean; itemCount?: number } = $props()
let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined)
let storages = resource(
@@ -19,14 +22,21 @@
: undefined
)
$effect(() => {
itemCount = storages.current?.length ?? 0
})
let behaviors: Record<string, 'isolated' | 'shared'> = $state({})
// Sent as `shared_storages` on the create-fork request; unlisted storages are isolated.
export function getSharedStorages(): string[] {
if (requireIsolation) return []
return (storages.current ?? []).filter((name) => behaviors[name] === 'shared')
}
let anyShared = $derived((storages.current ?? []).some((n) => behaviors[n] === 'shared'))
let anyShared = $derived(
!requireIsolation && (storages.current ?? []).some((n) => behaviors[n] === 'shared')
)
function storageLabel(name: string): string {
return name === '_default_' ? 'Primary storage' : name
@@ -35,21 +45,21 @@
<!-- Self-hides when the workspace has no object storage configured. -->
{#if storages.current && storages.current.length > 0}
<Label label="Object storage">
<span class="text-xs text-secondary">
By default each storage is isolated in the fork: the fork's writes go to its own prefix, and
it reads this workspace's files until it writes its own. Deleting the fork deletes what it
wrote.
</span>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-secondary">Object storage</span>
<div class="border rounded-md divide-y">
{#each storages.current as name (name)}
<div class="flex items-center gap-2 justify-between px-4 py-1.5">
<span class="text-xs font-medium">{storageLabel(name)}</span>
<Select
dropdownClass="max-w-96"
bind:value={() => behaviors[name] ?? 'isolated', (v) => (behaviors[name] = v)}
disabled={requireIsolation}
bind:value={
() => (requireIsolation ? 'isolated' : (behaviors[name] ?? 'isolated')),
(v) => (behaviors[name] = v)
}
items={[
{ value: 'isolated', label: 'Isolated (read through to parent)' },
{ value: 'isolated', label: 'Isolated' },
{ value: 'shared', label: 'Shared with parent' }
]}
/>
@@ -57,15 +67,10 @@
{/each}
</div>
{#if anyShared}
<div
class="flex items-start gap-2 px-3 py-2 text-xs rounded-md border border-amber-200 dark:border-amber-900/60 bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-300"
>
<TriangleAlert size={14} class="shrink-0 mt-0.5" />
<span>
A shared storage is NOT isolated: scripts in the fork read, overwrite and delete this
workspace's files directly.
</span>
</div>
<span class="flex items-center gap-1 text-2xs text-amber-700 dark:text-amber-300">
<TriangleAlert size={12} class="shrink-0" />
Scripts in the fork will overwrite and delete this workspace's files.
</span>
{/if}
</Label>
</div>
{/if}
@@ -45,6 +45,7 @@
let restrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment'))
let restrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing'))
let restrictGuestAppDeployment = $state(hasRule('RestrictGuestAppDeployment'))
let requireIsolatedForks = $state(hasRule('RequireIsolatedForks'))
let selectedGroups = $state<string[]>(
untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? []
)
@@ -60,6 +61,7 @@
let initialRestrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment'))
let initialRestrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing'))
let initialRestrictGuestAppDeployment = $state(hasRule('RestrictGuestAppDeployment'))
let initialRequireIsolatedForks = $state(hasRule('RequireIsolatedForks'))
let initialSelectedGroups = $state<string[]>(
untrack(() => rule)?.bypass_groups
? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', ''))
@@ -107,6 +109,7 @@
restrictAnonymousAppDeployment ||
restrictPublicRunSharing ||
restrictGuestAppDeployment ||
requireIsolatedForks ||
selectedGroups.length > 0 ||
selectedUsers.length > 0
: name !== initialName ||
@@ -116,6 +119,7 @@
restrictAnonymousAppDeployment !== initialRestrictAnonymousAppDeployment ||
restrictPublicRunSharing !== initialRestrictPublicRunSharing ||
restrictGuestAppDeployment !== initialRestrictGuestAppDeployment ||
requireIsolatedForks !== initialRequireIsolatedForks ||
JSON.stringify([...selectedGroups].sort()) !==
JSON.stringify([...initialSelectedGroups].sort()) ||
JSON.stringify([...selectedUsers].sort()) !==
@@ -155,7 +159,8 @@
...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []),
...(restrictGuestAppDeployment
? ['RestrictGuestAppDeployment' as ProtectionRuleKind]
: [])
: []),
...(requireIsolatedForks ? ['RequireIsolatedForks' as ProtectionRuleKind] : [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
@@ -194,7 +199,8 @@
...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []),
...(restrictGuestAppDeployment
? ['RestrictGuestAppDeployment' as ProtectionRuleKind]
: [])
: []),
...(requireIsolatedForks ? ['RequireIsolatedForks' as ProtectionRuleKind] : [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
@@ -211,6 +217,7 @@
initialRestrictAnonymousAppDeployment = restrictAnonymousAppDeployment
initialRestrictPublicRunSharing = restrictPublicRunSharing
initialRestrictGuestAppDeployment = restrictGuestAppDeployment
initialRequireIsolatedForks = requireIsolatedForks
initialSelectedGroups = clone(selectedGroups)
initialSelectedUsers = clone(selectedUsers)
@@ -295,6 +302,12 @@
<Toggle bind:checked={disableFork} options={{ right: 'Disable workspace forking' }} />
{@render description('Users cannot create forks of this workspace.')}
</div>
<div class="flex flex-col gap-1">
<Toggle bind:checked={requireIsolatedForks} options={{ right: 'Require isolated forks' }} />
{@render description(
"Forks of this workspace cannot share its data: every object storage and ducklake stays isolated, and every data table is cloned instead of pointing at this workspace's database."
)}
</div>
<div class="flex flex-col gap-1">
<Toggle
bind:checked={restrictDeployToDeployers}
+1 -1
View File
@@ -1467,7 +1467,7 @@ write a secret you were not given: keep the placeholder.
| \`git_sync\` | \`{ repositories: [...] }\`, each with \`git_repo_resource_path\`, \`script_path\` and \`settings\` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |
| \`datatable\` | \`{ datatables: { <name>: { database: { resource_type, resource_path? } } } }\`. Role permissions are \`datatable_permissions\`. |
| \`datatable_permissions\` | \`{ <data table>: { default_role, roles: { <role>: [tenants] } } }\`: who may connect to each data table as which role. Tenants are \`u/<user>\`, \`g/<group>\`, \`f/<folder>\` or \`*\`; \`admin\` is always a role, and \`default_role: null\` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |
| \`protection_rules\` | \`{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| \`protection_rules\` | \`{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`, \`RequireIsolatedForks\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| \`ducklake\` | \`{ ducklakes: { <name>: { catalog, storage, ... } } }\`. |
| \`dbt_warehouses\` | dbt warehouse connections. |
| \`default_app\` | Path of the app shown as the workspace home. |
@@ -46,7 +46,7 @@ write a secret you were not given: keep the placeholder.
| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |
| `datatable` | `{ datatables: { <name>: { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. |
| `datatable_permissions` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |
| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| `ducklake` | `{ ducklakes: { <name>: { catalog, storage, ... } } }`. |
| `dbt_warehouses` | dbt warehouse connections. |
| `default_app` | Path of the app shown as the workspace home. |
+1 -1
View File
@@ -41,7 +41,7 @@ write a secret you were not given: keep the placeholder.
| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |
| `datatable` | `{ datatables: { <name>: { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. |
| `datatable_permissions` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |
| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| `ducklake` | `{ ducklakes: { <name>: { catalog, storage, ... } } }`. |
| `dbt_warehouses` | dbt warehouse connections. |
| `default_app` | Path of the app shown as the workspace home. |