From 819c6cd283baa6c39f5a4ce0b6300514612b6400 Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Fri, 3 Jul 2026 15:31:10 +0100 Subject: [PATCH 1/2] policy: implement phase 2 grants nodeattrs settings ux --- e2e/mock-api.mjs | 50 ++++-- e2e/policy-phase2.spec.ts | 84 ++++++++++ src/routes/acls/+page.svelte | 15 ++ src/routes/acls/Grants.svelte | 223 ++++++++++++++++++++++++++ src/routes/acls/NodeAttrs.svelte | 114 +++++++++++++ src/routes/acls/PolicySettings.svelte | 40 +++++ 6 files changed, 511 insertions(+), 15 deletions(-) create mode 100644 e2e/policy-phase2.spec.ts create mode 100644 src/routes/acls/Grants.svelte create mode 100644 src/routes/acls/NodeAttrs.svelte create mode 100644 src/routes/acls/PolicySettings.svelte diff --git a/e2e/mock-api.mjs b/e2e/mock-api.mjs index f0d2c64..fdcb5b1 100644 --- a/e2e/mock-api.mjs +++ b/e2e/mock-api.mjs @@ -429,18 +429,7 @@ let preAuthKeys; let apiKeys; let userNodeMap; -function resetState() { - _nodeId = 0; - users = structuredClone(baseUsers); - nodes = createNodes(users); - preAuthKeys = createPreAuthKeys(users); - apiKeys = createApiKeys(); - userNodeMap = buildUserNodeMap(nodes, users); -} - -resetState(); - -const policy = JSON.stringify({ +const defaultPolicy = { groups: { 'group:admin': ['alice', 'bob'], 'group:dev': ['carol', 'dave', 'eve', 'frank.garcia', 'grace'], @@ -476,7 +465,21 @@ const policy = JSON.stringify({ { action: 'accept', src: ['group:admin'], dst: ['*'], users: ['root', 'headscale-user'] }, { action: 'accept', src: ['group:dev'], dst: ['tag:server'], users: ['deploy'] }, ], -}); +}; + +let policyDocumentText = JSON.stringify(defaultPolicy); + +function resetState() { + _nodeId = 0; + users = structuredClone(baseUsers); + nodes = createNodes(users); + preAuthKeys = createPreAuthKeys(users); + apiKeys = createApiKeys(); + userNodeMap = buildUserNodeMap(nodes, users); + policyDocumentText = JSON.stringify(defaultPolicy); +} + +resetState(); const versionInfo = { version: 'v0.28.0', @@ -569,7 +572,7 @@ const server = createServer((req, res) => { } if (path === '/api/v1/preauthkey') return json(res, { preAuthKeys }); if (path === '/api/v1/apikey') return json(res, { apiKeys }); - if (path === '/api/v1/policy') return json(res, { policy, updatedAt: new Date().toISOString() }); + if (path === '/api/v1/policy') return json(res, { policy: policyDocumentText, updatedAt: new Date().toISOString() }); if (path === '/api/v1/health') return json(res, healthInfo); // Single node GET: /api/v1/node/{id} @@ -653,7 +656,24 @@ const server = createServer((req, res) => { // ── PUT endpoints ─────────────────────────────────────────────────────── if (method === 'PUT') { - if (path === '/api/v1/policy') return json(res, { policy, updatedAt: new Date().toISOString() }); + if (path === '/api/v1/policy') { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + try { + const parsed = JSON.parse(body); + if (typeof parsed.policy === 'string') { + policyDocumentText = parsed.policy; + } + } catch { + // Keep existing policy content when payload is invalid. + } + return json(res, { policy: policyDocumentText, updatedAt: new Date().toISOString() }); + }); + return; + } } // ── DELETE endpoints ──────────────────────────────────────────────────── diff --git a/e2e/policy-phase2.spec.ts b/e2e/policy-phase2.spec.ts new file mode 100644 index 0000000..264865b --- /dev/null +++ b/e2e/policy-phase2.spec.ts @@ -0,0 +1,84 @@ +import { test, expect, type Page } from '@playwright/test'; + +const MOCK_URL = 'http://localhost:8081'; +const API_KEY = 'test-api-key'; + +async function seedAuth(page: Page) { + await page.goto('/'); + await page.evaluate( + ([url, key]) => { + localStorage.setItem('apiUrl', JSON.stringify(url)); + localStorage.setItem('apiKey', JSON.stringify(key)); + localStorage.setItem( + 'apiKeyInfo', + JSON.stringify({ + authorized: true, + expires: new Date(Date.now() + 86_400_000 * 90).toISOString(), + informedUnauthorized: false, + informedExpiringSoon: false, + }), + ); + }, + [MOCK_URL, API_KEY], + ); + await page.reload(); + await page.locator('[data-testid="app-shell"]').waitFor({ timeout: 10000 }); +} + +test.describe('policy builder phase 2', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page); + }); + + test('create and persist grants/nodeAttrs/randomizeClientPort', async ({ page }) => { + await page.goto('/acls/'); + + await page.getByRole('tab', { name: 'Grants' }).click(); + await page.getByRole('button', { name: 'Create Grant' }).click(); + await expect(page.getByRole('heading', { name: 'Grant #1' })).toBeVisible(); + await page.getByRole('button', { name: 'Apply Taildrive preset' }).first().click(); + await page.getByRole('button', { name: 'Apply app JSON' }).first().click(); + + await page.getByRole('tab', { name: 'Node Attributes' }).click(); + await page.getByRole('button', { name: 'Create Node Attribute Rule' }).click(); + await expect(page.getByRole('heading', { name: 'Node Attribute #1' })).toBeVisible(); + await page.getByRole('button', { name: 'drive:share' }).first().click(); + + await page.getByRole('tab', { name: 'Policy Settings' }).click(); + await page.getByLabel('randomizeClientPort').check(); + + await page.getByRole('tab', { name: 'Config' }).click(); + await page.getByRole('button', { name: 'Save Config' }).click(); + + await page.reload(); + await page.getByRole('tab', { name: 'Config' }).click(); + + await expect(page.getByText('"randomizeClientPort": true')).toBeVisible(); + await expect(page.getByText('"nodeAttrs"')).toBeVisible(); + await expect(page.getByText('tailscale.com/cap/drive')).toBeVisible(); + }); + + test('delete grant and persist deletion', async ({ page }) => { + await page.goto('/acls/'); + + await page.getByRole('tab', { name: 'Grants' }).click(); + await page.getByRole('button', { name: 'Create Grant' }).click(); + await page.getByRole('button', { name: 'Apply Taildrive preset' }).first().click(); + await page.getByRole('button', { name: 'Apply app JSON' }).first().click(); + + await page.getByRole('tab', { name: 'Config' }).click(); + await page.getByRole('button', { name: 'Save Config' }).click(); + + await page.getByRole('tab', { name: 'Grants' }).click(); + await page.getByTestId('delete-button').first().click(); + await page.getByTestId('confirm-delete').first().click(); + + await page.getByRole('tab', { name: 'Config' }).click(); + await page.getByRole('button', { name: 'Save Config' }).click(); + + await page.reload(); + await page.getByRole('tab', { name: 'Config' }).click(); + + await expect(page.getByText('tailscale.com/cap/drive')).not.toBeVisible(); + }); +}); diff --git a/src/routes/acls/+page.svelte b/src/routes/acls/+page.svelte index 1361512..3948e7c 100644 --- a/src/routes/acls/+page.svelte +++ b/src/routes/acls/+page.svelte @@ -5,7 +5,10 @@ import RawMdiCodeJSON from '~icons/mdi/code-json'; import RawMdiConsole from '~icons/mdi/console'; import RawMdiDevices from '~icons/mdi/devices'; + import RawMdiTune from '~icons/mdi/tune'; import RawMdiGroups from '~icons/mdi/account-group'; + import RawMdiKeyChain from '~icons/mdi/key-chain-variant'; + import RawMdiVectorPolyline from '~icons/mdi/vector-polyline'; import RawMdiSecurity from '~icons/mdi/security'; import RawMdiTag from '~icons/mdi/tag'; @@ -21,7 +24,10 @@ import Config from './Config.svelte'; import Groups from './Groups.svelte'; import Hosts from './Hosts.svelte'; + import NodeAttrs from './NodeAttrs.svelte'; + import PolicySettings from './PolicySettings.svelte'; import Policies from './Policies.svelte'; + import Grants from './Grants.svelte'; import TagOwners from './TagOwners.svelte' import SshRules from './SshRules.svelte'; @@ -37,6 +43,9 @@ { name: 'tag-owners', title: 'Tag Owners', logo: RawMdiTag }, { name: 'hosts', title: 'Hosts', logo: RawMdiDevices }, { name: 'policies', title: 'Policies', logo: RawMdiSecurity }, + { name: 'grants', title: 'Grants', logo: RawMdiKeyChain }, + { name: 'node-attrs', title: 'Node Attributes', logo: RawMdiVectorPolyline }, + { name: 'policy-settings', title: 'Policy Settings', logo: RawMdiTune }, { name: 'ssh', title: 'SSH', logo: RawMdiConsole }, { name: 'config', title: 'Config', logo: RawMdiCodeJSON }, ]; @@ -82,6 +91,12 @@ {:else if tabs[tabSet].name == 'policies'} + {:else if tabs[tabSet].name == 'grants'} + + {:else if tabs[tabSet].name == 'node-attrs'} + + {:else if tabs[tabSet].name == 'policy-settings'} + {:else if tabs[tabSet].name == 'ssh'} {:else if tabs[tabSet].name == 'config'} diff --git a/src/routes/acls/Grants.svelte b/src/routes/acls/Grants.svelte new file mode 100644 index 0000000..3a00ab8 --- /dev/null +++ b/src/routes/acls/Grants.svelte @@ -0,0 +1,223 @@ + + + +
+
+
+ +
+ + {#if grants.length === 0} +
+ No grants configured yet. Create one to define modern policy access rules. +
+ {/if} + + {#each grants as grant, idx} +
+
+

Grant #{idx + 1}

+ deleteGrant(idx)} disabled={loading} /> +
+ +
+
+ + removeItem(grant.src, item)} + /> +
+
+ + removeItem(grant.dst, item)} + /> +
+
+ + removeItem(grant.ip ?? [], item)} + /> +
+
+ + removeItem(grant.srcPosture ?? [], item)} + /> +
+
+
+ + +
+ removeItem(grant.via ?? [], item)} + /> +

Hint: via currently supports tag selectors only (for example `tag:relay`).

+
+
+
+ + +
+ +
+ +
+

Each capability key maps to a JSON array value. Use the Taildrive preset for a safe starter payload.

+
+
+
+ {/each} +
+
diff --git a/src/routes/acls/NodeAttrs.svelte b/src/routes/acls/NodeAttrs.svelte new file mode 100644 index 0000000..f845a87 --- /dev/null +++ b/src/routes/acls/NodeAttrs.svelte @@ -0,0 +1,114 @@ + + +
+
+
+ +
+ + {#if nodeAttrs.length === 0} +
+ No node attributes configured yet. Add one to target selectors and attach capabilities. +
+ {/if} + + {#each nodeAttrs as nodeAttr, idx} +
+
+

Node Attribute #{idx + 1}

+ deleteNodeAttr(idx)} disabled={loading} /> +
+ +
+
+ + removeItem(nodeAttr.target, item)} + /> +
+
+
+ +
+ + +
+
+ removeItem(nodeAttr.attr, item)} + /> +
+
+
+ {/each} +
+
diff --git a/src/routes/acls/PolicySettings.svelte b/src/routes/acls/PolicySettings.svelte new file mode 100644 index 0000000..6d8d80a --- /dev/null +++ b/src/routes/acls/PolicySettings.svelte @@ -0,0 +1,40 @@ + + +
+
+
+

Top-level policy settings

+
+ +
+ +

+ Enable random client source ports for outbound traffic. +

+
+
+
+
+
From bceb0424a02ea5c31af6d6c75183359be08e3f60 Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Fri, 3 Jul 2026 16:23:49 +0100 Subject: [PATCH 2/2] policy: convert grants app editor to form builder --- e2e/policy-phase2.spec.ts | 4 +- src/routes/acls/Grants.svelte | 142 +++++++++++++++++++++++++++++----- 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/e2e/policy-phase2.spec.ts b/e2e/policy-phase2.spec.ts index 264865b..ecfb1da 100644 --- a/e2e/policy-phase2.spec.ts +++ b/e2e/policy-phase2.spec.ts @@ -37,7 +37,7 @@ test.describe('policy builder phase 2', () => { await page.getByRole('button', { name: 'Create Grant' }).click(); await expect(page.getByRole('heading', { name: 'Grant #1' })).toBeVisible(); await page.getByRole('button', { name: 'Apply Taildrive preset' }).first().click(); - await page.getByRole('button', { name: 'Apply app JSON' }).first().click(); + await page.getByRole('button', { name: 'Apply capability form' }).first().click(); await page.getByRole('tab', { name: 'Node Attributes' }).click(); await page.getByRole('button', { name: 'Create Node Attribute Rule' }).click(); @@ -64,7 +64,7 @@ test.describe('policy builder phase 2', () => { await page.getByRole('tab', { name: 'Grants' }).click(); await page.getByRole('button', { name: 'Create Grant' }).click(); await page.getByRole('button', { name: 'Apply Taildrive preset' }).first().click(); - await page.getByRole('button', { name: 'Apply app JSON' }).first().click(); + await page.getByRole('button', { name: 'Apply capability form' }).first().click(); await page.getByRole('tab', { name: 'Config' }).click(); await page.getByRole('button', { name: 'Save Config' }).click(); diff --git a/src/routes/acls/Grants.svelte b/src/routes/acls/Grants.svelte index 3a00ab8..d950416 100644 --- a/src/routes/acls/Grants.svelte +++ b/src/routes/acls/Grants.svelte @@ -8,6 +8,12 @@ import { deduplicate, toastError, toastSuccess } from '$lib/common/funcs'; import { debug } from '$lib/common/debug'; + type CapabilityFormRow = { + id: string; + key: string; + value: string; + }; + const ToastStore = getToastStore(); let { acl = $bindable(), loading = $bindable(false) }: { acl: PolicyBuilder; loading?: boolean } = $props(); @@ -24,7 +30,8 @@ ); const viaTagOptions = $derived(deduplicate(acl.getTagNames(true))); - let appEditors = $state>({}); + let capabilityForms = $state>({}); + let capabilityRowCounter = $state(0); const grants = $derived.by(() => { const list = acl.grants ?? []; @@ -36,6 +43,31 @@ return list; }); + $effect(() => { + const list = grants; + const next: Record = { ...capabilityForms }; + let changed = false; + + for (let idx = 0; idx < list.length; idx += 1) { + if (next[idx] === undefined) { + next[idx] = rowsFromGrant(list[idx]); + changed = true; + } + } + + for (const k of Object.keys(next)) { + const idx = Number.parseInt(k, 10); + if (idx >= list.length) { + delete next[idx]; + changed = true; + } + } + + if (changed) { + capabilityForms = next; + } + }); + function currentGrants(): PolicyGrant[] { return acl.grants ?? []; } @@ -49,6 +81,8 @@ ...currentGrants(), { src: ['*'], dst: ['*'], ip: [], srcPosture: [], via: [] } satisfies PolicyGrant, ]; + const idx = next.length - 1; + capabilityForms[idx] = [{ id: `cap-${capabilityRowCounter++}`, key: '', value: '[]' }]; updateGrants(next); toastSuccess(`Added grant #${next.length}`, ToastStore); } @@ -57,7 +91,20 @@ const next = [...currentGrants()]; next.splice(idx, 1); updateGrants(next); - delete appEditors[idx]; + reindexCapabilityFormsAfterDelete(idx); + } + + function reindexCapabilityFormsAfterDelete(deletedIdx: number) { + const next: Record = {}; + for (const [k, v] of Object.entries(capabilityForms)) { + const idx = Number.parseInt(k, 10); + if (idx < deletedIdx) { + next[idx] = v; + } else if (idx > deletedIdx) { + next[idx - 1] = v; + } + } + capabilityForms = next; } function removeItem(items: string[], item: string) { @@ -76,24 +123,59 @@ grant.app = {}; } grant.app['tailscale.com/cap/drive'] = [{ shares: ['*'] }, { access: ['*'] }]; - appEditors[idx] = JSON.stringify(grant.app, null, 2); + capabilityForms[idx] = [ + { + id: `cap-${capabilityRowCounter++}`, + key: 'tailscale.com/cap/drive', + value: JSON.stringify([{ shares: ['*'] }, { access: ['*'] }], null, 2), + }, + ]; updateGrants([...currentGrants()]); toastSuccess('Applied Taildrive app preset', ToastStore); } - function applyAppJson(idx: number) { + function rowsFromGrant(grant: PolicyGrant): CapabilityFormRow[] { + const rows: CapabilityFormRow[] = Object.entries(grant.app ?? {}).map(([key, value]) => ({ + id: `cap-${capabilityRowCounter++}`, + key, + value: JSON.stringify(value, null, 2), + })); + return rows.length > 0 + ? rows + : [{ id: `cap-${capabilityRowCounter++}`, key: '', value: '[]' }]; + } + + function addCapabilityRow(idx: number) { + const rows = capabilityForms[idx] ?? []; + rows.push({ id: `cap-${capabilityRowCounter++}`, key: '', value: '[]' }); + capabilityForms[idx] = rows; + } + + function removeCapabilityRow(idx: number, id: string) { + const rows = capabilityForms[idx] ?? []; + capabilityForms[idx] = rows.filter((row) => row.id !== id); + } + + function applyCapabilityForm(idx: number) { const grant = currentGrants()[idx]; if (grant === undefined) { return; } try { - const text = appEditors[idx] ?? '{}'; - const parsed = JSON.parse(text) as Record; + const rows = capabilityForms[idx] ?? []; const normalised: Record = {}; - for (const [key, value] of Object.entries(parsed)) { + for (const row of rows) { + const key = row.key.trim(); + if (key.length === 0) { + continue; + } + const value = JSON.parse(row.value) as unknown; if (!Array.isArray(value)) { throw new Error(`Capability '${key}' must be a JSON array`); } + if (Object.prototype.hasOwnProperty.call(normalised, key)) { + throw new Error(`Duplicate capability key '${key}'`); + } normalised[key] = value; } grant.app = Object.keys(normalised).length > 0 ? normalised : undefined; @@ -196,23 +278,45 @@
- +
- +
+ {#each (capabilityForms[idx] ?? []) as row (row.id)} +
+
+
+ + +
+
+ +
+
+ + +
+ {/each} +
- + +

Each capability key maps to a JSON array value. Use the Taildrive preset for a safe starter payload.