Skip to content

Commit 09e1412

Browse files
committed
waf: copy zone to another location
Add a Copy action that clones a WAF zone's rules and rate limits into another WAF-capable location using only waf.list / waf.set: - New WafCopyModal (self-contained: fetches waf.list on open, re-checks at confirm so a zone created on the target meanwhile is never silently replaced). Rule/limit ids are sent blank so the server mints fresh ids. A per-open generation token discards late waf.list responses from a superseded or dismissed open. - Two entry points: a per-row Copy button on /waf and a 'Copy to location' button on /waf/manage, both gated on [waf.set, waf.list] via GuardedButton. - Targets are WAF-capable locations with no zone yet (v1: no overwrite). - Unify the create-page loader onto one waf.list instead of the per-location waf.get fan-out; a forbidden waf.list falls back to the fan-out so a waf.get-only role keeps an accurately filtered page. - Playwright coverage: copy flow with id stripping, no-eligible-target state, manage-page entry point, permission gating (both grants required), the confirm-time occupied-target abort, and the create-loader waf.get fallback.
1 parent de96294 commit 09e1412

5 files changed

Lines changed: 453 additions & 20 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
<script lang="ts">
2+
import Select from '$lib/components/Select.svelte'
3+
import * as modal from '$lib/modal'
4+
import api from '$lib/api'
5+
6+
/**
7+
* Copy a WAF zone's rules + rate limits into another WAF-capable location
8+
* that has no zone yet, using only waf.list / waf.set. Rule and limit ids are
9+
* server-managed and resolve against the TARGET zone, so the copy sends them
10+
* blank ("") and the server mints fresh ids — which also means the copy
11+
* starts with fresh metric series.
12+
*
13+
* The modal is self-contained: open(source) fetches a fresh waf.list so
14+
* eligibility (empty targets) never trusts possibly-stale page data, and
15+
* confirm re-fetches it to shrink the open→confirm race in which someone
16+
* else creates a zone on the target (waf.set is a whole-zone upsert, so
17+
* losing that race would silently replace their zone).
18+
*/
19+
20+
interface Props {
21+
project: string
22+
locations: Api.Location[]
23+
}
24+
25+
const { project, locations }: Props = $props()
26+
27+
// Invalidates the async continuation of a superseded or dismissed open():
28+
// a late waf.list response must not overwrite state opened for another
29+
// source, nor pop errors after the user closed the modal.
30+
let openGen = 0
31+
32+
let isActive = $state(false)
33+
let loading = $state(false)
34+
let submitting = $state(false)
35+
let source = $state('')
36+
let zones = $state<Api.WafZone[]>([])
37+
let target = $state('')
38+
let description = $state('')
39+
40+
const sourceZone = $derived(zones.find((z) => z.location === source))
41+
42+
// Eligible targets = WAF-capable ∧ no zone ∧ not the source. `locations`
43+
// carries the layout's session-cached feature flags; `zones` is the fresh
44+
// list fetched at open/confirm, so a pending zone (deploying or deleting)
45+
// counts as occupied.
46+
const eligible = $derived(locations.filter((loc) =>
47+
loc.features.waf &&
48+
loc.id !== source &&
49+
!zones.some((z) => z.location === loc.id)))
50+
51+
export async function open (sourceLocation: string): Promise<void> {
52+
const gen = ++openGen
53+
isActive = true
54+
loading = true
55+
submitting = false
56+
source = sourceLocation
57+
target = ''
58+
description = ''
59+
zones = []
60+
61+
const resp = await api.invoke<Api.WafZoneList>('waf.list', { project }, fetch)
62+
if (gen !== openGen) return
63+
loading = false
64+
if (!resp.ok) {
65+
isActive = false
66+
modal.error({ error: resp.error })
67+
return
68+
}
69+
zones = resp.result?.items ?? []
70+
71+
const src = zones.find((z) => z.location === sourceLocation)
72+
if (!src) {
73+
// Deleted underneath us — surface it and refresh the stale page list.
74+
isActive = false
75+
modal.error({ error: `The firewall in ${sourceLocation} no longer exists.` })
76+
await api.invalidate('waf.list')
77+
return
78+
}
79+
description = src.description
80+
}
81+
82+
function close () {
83+
if (submitting) return
84+
openGen++
85+
isActive = false
86+
}
87+
88+
async function copy () {
89+
if (!target || submitting) return
90+
91+
submitting = true
92+
try {
93+
// Confirm-time re-check: the modal can sit open indefinitely, and
94+
// waf.set has no compare-and-set — re-list so a zone created on the
95+
// target in the meantime is never silently replaced.
96+
const listResp = await api.invoke<Api.WafZoneList>('waf.list', { project }, fetch)
97+
if (!listResp.ok) {
98+
modal.error({ error: listResp.error })
99+
return
100+
}
101+
zones = listResp.result?.items ?? []
102+
103+
const src = zones.find((z) => z.location === source)
104+
if (!src) {
105+
isActive = false
106+
modal.error({ error: `The firewall in ${source} no longer exists.` })
107+
await api.invalidate('waf.list')
108+
return
109+
}
110+
if (zones.some((z) => z.location === target)) {
111+
const taken = target
112+
target = ''
113+
modal.error({ error: `A firewall was just created in ${taken}.` })
114+
return
115+
}
116+
117+
const resp = await api.invoke('waf.set', {
118+
project,
119+
location: target,
120+
description,
121+
rules: (src.rules ?? []).map((r) => ({ ...r, id: '' })),
122+
limits: (src.limits ?? []).map((l) => ({ ...l, id: '' }))
123+
}, fetch)
124+
if (!resp.ok) {
125+
modal.error({ error: resp.error })
126+
return
127+
}
128+
129+
isActive = false
130+
await api.invalidate('waf.list')
131+
modal.success({ content: `Firewall copied to ${target}. It is now deploying.` })
132+
} finally {
133+
submitting = false
134+
}
135+
}
136+
</script>
137+
138+
<div class="modal" onclick={close} class:is-active={isActive} aria-hidden={!isActive}>
139+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
140+
<div class="modal-panel" onclick={(e) => e.stopPropagation()}>
141+
<div class="modal-close" onclick={close} onkeypress={close} tabindex="0" role="button">✕</div>
142+
<h4><strong>Copy firewall</strong></h4>
143+
144+
{#if loading}
145+
<p class="mt-4">Loading…</p>
146+
{:else if sourceZone}
147+
{#if eligible.length === 0}
148+
<p class="mt-4">
149+
Every WAF-capable location already has a firewall. Disable an
150+
existing firewall first to copy over it.
151+
</p>
152+
<div class="actions mt-6">
153+
<button class="button is-variant-tertiary" onclick={close}>Close</button>
154+
</div>
155+
{:else}
156+
<p class="mt-4">
157+
Copies
158+
<strong>{sourceZone.rules?.length ?? 0} {(sourceZone.rules?.length ?? 0) === 1 ? 'rule' : 'rules'}</strong>
159+
and
160+
<strong>{sourceZone.limits?.length ?? 0} {(sourceZone.limits?.length ?? 0) === 1 ? 'rate limit' : 'rate limits'}</strong>
161+
from <span class="font-mono">{source}</span>.
162+
</p>
163+
<p class="hint mt-2">
164+
The copy starts with fresh metric series — match history stays with
165+
the source zone.
166+
</p>
167+
168+
<div class="field mt-4">
169+
<label for="copy-target-location">Target location</label>
170+
<Select
171+
id="copy-target-location"
172+
bind:value={target}
173+
required
174+
placeholder="Select Location"
175+
options={eligible.map((loc) => ({ value: loc.id, label: loc.id }))} />
176+
</div>
177+
178+
<div class="field mt-4">
179+
<label for="copy-description">Description</label>
180+
<div class="input">
181+
<input id="copy-description" bind:value={description} placeholder="Optional description">
182+
</div>
183+
</div>
184+
185+
<div class="actions mt-6">
186+
<button class="button is-variant-tertiary" onclick={close} disabled={submitting}>Cancel</button>
187+
<button class="button" class:is-loading={submitting}
188+
onclick={copy} disabled={!target || submitting}>
189+
Copy firewall
190+
</button>
191+
</div>
192+
{/if}
193+
{/if}
194+
</div>
195+
</div>
196+
197+
<style>
198+
.modal-panel {
199+
width: 100%;
200+
max-width: 32rem;
201+
}
202+
203+
.hint {
204+
font-size: 0.8125rem;
205+
color: hsl(var(--hsl-content) / 0.55);
206+
}
207+
208+
.actions {
209+
display: flex;
210+
justify-content: flex-end;
211+
gap: 1rem;
212+
}
213+
</style>

src/routes/(auth)/(project)/waf/+page.svelte

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import ErrorRow from '$lib/components/ErrorRow.svelte'
55
import Sparkline from '$lib/components/Sparkline.svelte'
66
import GuardedButton from '$lib/components/GuardedButton.svelte'
7+
import WafCopyModal from '$lib/components/WafCopyModal.svelte'
78
import { onMount, untrack } from 'svelte'
89
import api from '$lib/api'
910
import * as format from '$lib/format'
@@ -16,6 +17,8 @@
1617
const firewalls = $derived(data.firewalls)
1718
const error = $derived(data.error)
1819
20+
let copyModal = $state<WafCopyModal>()
21+
1922
const { can } = getPermissionContext()
2023
$effect(() => {
2124
if (!can('waf.set')) return
@@ -177,6 +180,11 @@
177180
</td>
178181
<td>
179182
<div class="flex gap-1 justify-end">
183+
<GuardedButton permission={['waf.set', 'waf.list']} class="button is-variant-secondary is-size-small"
184+
aria-label={`Copy firewall in ${fw.location}`}
185+
onclick={() => copyModal?.open(fw.location)}>
186+
Copy
187+
</GuardedButton>
180188
<a class="button is-variant-secondary is-size-small"
181189
href={`/waf/manage?project=${project}&location=${encodeURIComponent(fw.location)}`}>
182190
Manage
@@ -200,6 +208,8 @@
200208
</div>
201209
</div>
202210

211+
<WafCopyModal bind:this={copyModal} {project} locations={data.locations} />
212+
203213
<style>
204214
/* Hold a fixed box across the loading / loaded / empty states: min-height
205215
matches the Sparkline's height (28px) and min-width its count + chart, so

src/routes/(auth)/(project)/waf/create/+page.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,30 @@ import type { PageLoad } from './$types'
44
export const load: PageLoad = async ({ parent, fetch }) => {
55
const { project, locations } = await parent() as { project: string, locations: Api.Location[] }
66

7-
// Only locations whose backend supports the WAF can host a firewall; drop the
8-
// rest before the fan-out (also saves a waf.get per unsupported location).
7+
// Only locations whose backend supports the WAF can host a firewall.
98
const supported = locations.filter((loc) => loc.features.waf)
109

11-
// No "list zones" endpoint — fan out waf.get to discover which locations are
12-
// already configured, then offer only the unconfigured ones for create.
13-
const configured = await Promise.all(
14-
supported.map(async (loc) => {
15-
const res = await api.invoke<Api.WafZone>('waf.get', { project, location: loc.id }, fetch)
16-
return res.ok && res.result ? loc.id : null
17-
})
18-
)
19-
const configuredSet = configured.filter((id) => id != null)
10+
// One waf.list discovers every configured location; offer only the
11+
// unconfigured ones for create. waf.list is its own IAM check, separate
12+
// from waf.get — a role holding waf.get without waf.list falls back to the
13+
// per-location waf.get fan-out so its page stays accurately filtered. With
14+
// neither grant, a non-ok get counts as unconfigured and every location is
15+
// offered rather than erroring the page — the server still enforces
16+
// waf.set on submit.
17+
const res = await api.invoke<Api.WafZoneList>('waf.list', { project }, fetch)
18+
let configured: string[]
19+
if (res.ok) {
20+
configured = (res.result?.items ?? []).map((z) => z.location)
21+
} else {
22+
configured = (await Promise.all(
23+
supported.map(async (loc) => {
24+
const r = await api.invoke<Api.WafZone>('waf.get', { project, location: loc.id }, fetch)
25+
return r.ok && r.result ? loc.id : null
26+
})
27+
)).filter((id) => id != null)
28+
}
2029

21-
const available = supported.filter((loc) => !configuredSet.includes(loc.id))
30+
const available = supported.filter((loc) => !configured.includes(loc.id))
2231

2332
return { project, locations: available }
2433
}

src/routes/(auth)/(project)/waf/manage/+page.svelte

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import DangerZone from '$lib/components/DangerZone.svelte'
88
import GuardedButton from '$lib/components/GuardedButton.svelte'
99
import WafTestPanel from '$lib/components/WafTestPanel.svelte'
10+
import WafCopyModal from '$lib/components/WafCopyModal.svelte'
1011
import type { RuleForm } from '$lib/waf/rules'
1112
import { actionLabels, normalizeRules, toApiRules } from '$lib/waf/rules'
1213
import type { LimitForm } from '$lib/waf/limits'
@@ -17,6 +18,8 @@
1718
const project = $derived(data.project)
1819
const location = $derived(data.location)
1920
21+
let copyModal = $state<WafCopyModal>()
22+
2023
// The list reflects SERVER state for this location. Navigating away and back
2124
// (e.g. from the edit page) reloads the loader, which re-seeds this copy.
2225
let description = $state(untrack(() => data.zone?.description ?? ''))
@@ -183,11 +186,18 @@
183186
Rules that filter incoming traffic in <span class="font-mono">{location}</span>
184187
</p>
185188
</div>
186-
<a class="button is-variant-secondary is-icon-left"
187-
href={`/waf/metrics?project=${project}&location=${encodeURIComponent(location)}`}>
188-
<i class="fa-solid fa-chart-simple"></i>
189-
View metrics
190-
</a>
189+
<div class="flex gap-3">
190+
<GuardedButton permission={['waf.set', 'waf.list']} class="button is-variant-secondary is-icon-left"
191+
onclick={() => copyModal?.open(location)}>
192+
<i class="fa-solid fa-copy"></i>
193+
Copy to location
194+
</GuardedButton>
195+
<a class="button is-variant-secondary is-icon-left"
196+
href={`/waf/metrics?project=${project}&location=${encodeURIComponent(location)}`}>
197+
<i class="fa-solid fa-chart-simple"></i>
198+
View metrics
199+
</a>
200+
</div>
191201
</div>
192202

193203
<div class="panel is-level-300 grid gap-6">
@@ -395,6 +405,8 @@
395405
</div>
396406
</div>
397407

408+
<WafCopyModal bind:this={copyModal} {project} locations={data.locations} />
409+
398410
<style>
399411
.action-badge {
400412
display: inline-flex;

0 commit comments

Comments
 (0)