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..ecfb1da --- /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 capability form' }).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 capability form' }).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..d950416 --- /dev/null +++ b/src/routes/acls/Grants.svelte @@ -0,0 +1,327 @@ + + + +
+
+
+ +
+ + {#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 (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.

+
+
+
+ {/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. +

+
+
+
+
+