mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 00:03:21 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
9375805fdf
commit
ab4e790a21
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -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. |
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user