From a1ee55d7b458c69fe747942cd942c1f89916cbf3 Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Fri, 3 Jul 2026 12:01:59 +0100 Subject: [PATCH 1/5] policy: add phase-1 modern policy model bridge --- src/lib/common/acl.svelte.ts | 89 +++++++++++++++++++++++++- src/lib/common/policy-builder.test.ts | 59 +++++++++++++++++ src/lib/common/policy-builder.ts | 8 +++ src/lib/common/policy-document.test.ts | 9 ++- src/lib/common/policy-document.ts | 82 ++++++++++++++++++++---- src/lib/common/policy-types.ts | 53 +++++++++++++++ src/routes/acls/+page.svelte | 11 ++-- src/routes/acls/Config.svelte | 12 ++-- 8 files changed, 294 insertions(+), 29 deletions(-) create mode 100644 src/lib/common/policy-builder.test.ts create mode 100644 src/lib/common/policy-builder.ts create mode 100644 src/lib/common/policy-types.ts diff --git a/src/lib/common/acl.svelte.ts b/src/lib/common/acl.svelte.ts index 4bb2569..35eee7d 100644 --- a/src/lib/common/acl.svelte.ts +++ b/src/lib/common/acl.svelte.ts @@ -4,6 +4,7 @@ import { setPolicy } from './api' import type { ToastStore } from '@skeletonlabs/skeleton' import { debug } from './debug' import { parsePolicyDocument, serialisePolicyDocument, type LegacyPolicySection } from './policy-document' +import type { PolicyGrant, PolicyNodeAttr, PolicyPosture, PolicySshTest, PolicyTest } from './policy-types' export type TagOwners = string[] export type TagOwnersTyped = { users: string[], groups: string[], tags: string[] } @@ -55,6 +56,12 @@ export type ACL = { hosts: AclHosts, // keys are DNS-style hostnames acls: AclPolicies, ssh?: AclSshRules, + grants?: PolicyGrant[], + nodeAttrs?: PolicyNodeAttr[], + tests?: PolicyTest[], + sshTests?: PolicySshTest[], + postures?: PolicyPosture[], + randomizeClientPort?: boolean, } export type PrefixType = "group" | "tag" @@ -77,6 +84,12 @@ export class ACLBuilder implements ACL { hosts = $state({}) acls = $state([]) ssh = $state(undefined) + grants = $state(undefined) + nodeAttrs = $state(undefined) + tests = $state(undefined) + sshTests = $state(undefined) + postures = $state(undefined) + randomizeClientPort = $state(undefined) constructor( groups: AclGroups, @@ -84,6 +97,12 @@ export class ACLBuilder implements ACL { hosts: AclHosts, acls: AclPolicies, ssh?: AclSshRules, + grants?: PolicyGrant[], + nodeAttrs?: PolicyNodeAttr[], + tests?: PolicyTest[], + sshTests?: PolicySshTest[], + postures?: PolicyPosture[], + randomizeClientPort?: boolean, policyRaw?: Record, unsupportedPolicyFields: string[] = [], ) { @@ -92,6 +111,12 @@ export class ACLBuilder implements ACL { this.hosts = hosts this.acls = acls this.ssh = ssh + this.grants = grants + this.nodeAttrs = nodeAttrs + this.tests = tests + this.sshTests = sshTests + this.postures = postures + this.randomizeClientPort = randomizeClientPort this.#policyRaw = policyRaw this.#unsupportedPolicyFields = unsupportedPolicyFields } @@ -103,6 +128,12 @@ export class ACLBuilder implements ACL { hosts: this.hosts, acls: this.acls, ssh: this.ssh, + grants: this.grants, + nodeAttrs: this.nodeAttrs, + tests: this.tests, + sshTests: this.sshTests, + postures: this.postures, + randomizeClientPort: this.randomizeClientPort, } if (this.#policyRaw !== undefined) { @@ -121,7 +152,7 @@ export class ACLBuilder implements ACL { } static emptyACL(): ACLBuilder { - return new ACLBuilder({}, {}, {}, [], []) + return new ACLBuilder({}, {}, {}, [], [], [], [], [], [], [], undefined) } static defaultACL(): ACLBuilder { @@ -130,7 +161,7 @@ export class ACLBuilder implements ACL { action: "accept", src: ["*"], dst: ["*:*"], - }], []) + }], [], [], [], [], [], [], undefined) } static addPolicyMeta(policy: AclPolicy): boolean { @@ -150,11 +181,65 @@ export class ACLBuilder implements ACL { { ...doc.legacy.hosts }, [...doc.legacy.acls as AclPolicies], [...ssh], + doc.legacy.grants ? [...doc.legacy.grants] : undefined, + doc.legacy.nodeAttrs ? [...doc.legacy.nodeAttrs] : undefined, + doc.legacy.tests ? [...doc.legacy.tests] : undefined, + doc.legacy.sshTests ? [...doc.legacy.sshTests] : undefined, + doc.legacy.postures ? [...doc.legacy.postures] : undefined, + doc.legacy.randomizeClientPort, doc.raw, doc.unsupportedFields, ) } + setGrants(grants: PolicyGrant[] | undefined) { + this.grants = grants === undefined ? undefined : [...grants] + } + + getGrants(): PolicyGrant[] | undefined { + return this.grants + } + + setNodeAttrs(nodeAttrs: PolicyNodeAttr[] | undefined) { + this.nodeAttrs = nodeAttrs === undefined ? undefined : [...nodeAttrs] + } + + getNodeAttrs(): PolicyNodeAttr[] | undefined { + return this.nodeAttrs + } + + setTests(tests: PolicyTest[] | undefined) { + this.tests = tests === undefined ? undefined : [...tests] + } + + getTests(): PolicyTest[] | undefined { + return this.tests + } + + setSshTests(sshTests: PolicySshTest[] | undefined) { + this.sshTests = sshTests === undefined ? undefined : [...sshTests] + } + + getSshTests(): PolicySshTest[] | undefined { + return this.sshTests + } + + setPostures(postures: PolicyPosture[] | undefined) { + this.postures = postures === undefined ? undefined : [...postures] + } + + getPostures(): PolicyPosture[] | undefined { + return this.postures + } + + setRandomizeClientPort(randomizeClientPort: boolean | undefined) { + this.randomizeClientPort = randomizeClientPort + } + + getRandomizeClientPort(): boolean | undefined { + return this.randomizeClientPort + } + private static getPrefix(name: string): PrefixType | null { for (const [prefixType, prefix] of Object.entries(Prefixes)) { if (name.startsWith(prefix)) { diff --git a/src/lib/common/policy-builder.test.ts b/src/lib/common/policy-builder.test.ts new file mode 100644 index 0000000..aca8920 --- /dev/null +++ b/src/lib/common/policy-builder.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import JWCC from 'json5' +import { PolicyBuilder } from '$lib/common/policy-builder' + +describe('PolicyBuilder bridge', () => { + it('loads and serialises modern policy sections', () => { + const source = { + groups: { 'group:admins': ['alice'] }, + tagOwners: { 'tag:prod': ['group:admins'] }, + hosts: { db: '100.64.0.10' }, + acls: [{ action: 'accept', src: ['*'], dst: ['*:*'] }], + grants: [{ src: ['group:admins'], dst: ['tag:prod'], via: ['tag:relay'] }], + nodeAttrs: [{ target: ['tag:prod'], attr: ['funnel'] }], + tests: [{ src: 'group:admins' }], + sshTests: [{ src: 'group:admins' }], + postures: [{ name: 'corp-devices', rules: [] }], + randomizeClientPort: true, + unknownTopLevel: { keep: true }, + } + + const builder = PolicyBuilder.fromPolicy(source) + + expect(builder.getGrants()).toEqual(source.grants) + expect(builder.getNodeAttrs()).toEqual(source.nodeAttrs) + expect(builder.getTests()).toEqual(source.tests) + expect(builder.getSshTests()).toEqual(source.sshTests) + expect(builder.getPostures()).toEqual(source.postures) + expect(builder.getRandomizeClientPort()).toBe(true) + expect(builder.hasUnsupportedPolicyFields()).toBe(true) + expect(builder.getUnsupportedPolicyFields()).toContain('unknownTopLevel') + + const out = JWCC.parse(builder.JSON(2)) + expect((out as any).grants).toEqual(source.grants) + expect((out as any).nodeAttrs).toEqual(source.nodeAttrs) + expect((out as any).tests).toEqual(source.tests) + expect((out as any).sshTests).toEqual(source.sshTests) + expect((out as any).postures).toEqual(source.postures) + expect((out as any).randomizeClientPort).toBe(true) + expect((out as any).unknownTopLevel).toEqual({ keep: true }) + }) + + it('updates modern sections through bridge setters', () => { + const builder = PolicyBuilder.defaultACL() + + builder.setGrants([{ src: ['group:ops'], dst: ['tag:prod'] }]) + builder.setNodeAttrs([{ target: ['tag:prod'], attr: ['funnel'] }]) + builder.setTests([{ src: 'group:ops' }]) + builder.setSshTests([{ src: 'group:ops' }]) + builder.setPostures([{ name: 'corp', rules: [] }]) + builder.setRandomizeClientPort(false) + + expect(builder.getGrants()).toEqual([{ src: ['group:ops'], dst: ['tag:prod'] }]) + expect(builder.getNodeAttrs()).toEqual([{ target: ['tag:prod'], attr: ['funnel'] }]) + expect(builder.getTests()).toEqual([{ src: 'group:ops' }]) + expect(builder.getSshTests()).toEqual([{ src: 'group:ops' }]) + expect(builder.getPostures()).toEqual([{ name: 'corp', rules: [] }]) + expect(builder.getRandomizeClientPort()).toBe(false) + }) +}) diff --git a/src/lib/common/policy-builder.ts b/src/lib/common/policy-builder.ts new file mode 100644 index 0000000..fbe814e --- /dev/null +++ b/src/lib/common/policy-builder.ts @@ -0,0 +1,8 @@ +import { ACLBuilder } from './acl.svelte' + +// Bridge export for the modern policy model. +// The underlying implementation remains ACL-compatible while supporting +// additional 0.29.x policy sections (grants, nodeAttrs, tests, sshTests, +// postures, randomizeClientPort). +export const PolicyBuilder = ACLBuilder +export type PolicyBuilder = ACLBuilder diff --git a/src/lib/common/policy-document.test.ts b/src/lib/common/policy-document.test.ts index 895b213..fb0d329 100644 --- a/src/lib/common/policy-document.test.ts +++ b/src/lib/common/policy-document.test.ts @@ -22,9 +22,12 @@ describe('policy-document parse/serialise', () => { const out = serialisePolicyDocument(doc.raw, doc.legacy, 2) expect(JWCC.parse(out)).toEqual(JWCC.parse(source)) - expect(doc.unsupportedFields).toContain('grants') - expect(doc.unsupportedFields).toContain('nodeAttrs') - expect(doc.unsupportedFields).toContain('randomizeClientPort') + expect(doc.legacy.grants).toBeDefined() + expect(doc.legacy.nodeAttrs).toBeDefined() + expect(doc.legacy.randomizeClientPort).toBe(true) + expect(doc.unsupportedFields).not.toContain('grants') + expect(doc.unsupportedFields).not.toContain('nodeAttrs') + expect(doc.unsupportedFields).not.toContain('randomizeClientPort') }) it('merges edited legacy ACL sections while keeping unknown top-level fields', () => { diff --git a/src/lib/common/policy-document.ts b/src/lib/common/policy-document.ts index 9351241..2063965 100644 --- a/src/lib/common/policy-document.ts +++ b/src/lib/common/policy-document.ts @@ -1,14 +1,16 @@ import JWCC from 'json5' - -type JsonObject = Record - -export type LegacyPolicySection = { - groups: Record - tagOwners: Record - hosts: Record - acls: Array> - ssh?: Array> -} +import type { + JsonObject, + PolicyGrant, + PolicyNodeAttr, + PolicyPosture, + PolicySections, + PolicySshRule, + PolicySshTest, + PolicyTest, +} from './policy-types' + +export type LegacyPolicySection = PolicySections export type PolicyDocument = { raw: JsonObject @@ -19,6 +21,14 @@ export type PolicyDocument = { const LEGACY_TOP_LEVEL_KEYS = new Set(['groups', 'tagOwners', 'hosts', 'acls', 'ssh']) const LEGACY_ACL_RULE_KEYS = new Set(['#ha-meta', 'action', 'proto', 'src', 'dst']) const LEGACY_SSH_RULE_KEYS = new Set(['action', 'src', 'dst', 'users']) +const MODERN_TOP_LEVEL_KEYS = new Set([ + 'grants', + 'nodeAttrs', + 'tests', + 'sshTests', + 'postures', + 'randomizeClientPort', +]) function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -32,18 +42,18 @@ function asObject(value: unknown, fallback: JsonObject = {}): JsonObject { return isObject(value) ? value : fallback } -function asArray(value: unknown, fallback: Array> = []): Array> { +function asArray>(value: unknown, fallback: T[] = []): T[] { if (!Array.isArray(value)) { return fallback } - return value.filter(isObject) + return value.filter(isObject) as T[] } function collectUnsupportedFields(raw: JsonObject): string[] { const unsupported = new Set() for (const key of Object.keys(raw)) { - if (!LEGACY_TOP_LEVEL_KEYS.has(key)) { + if (!LEGACY_TOP_LEVEL_KEYS.has(key) && !MODERN_TOP_LEVEL_KEYS.has(key)) { unsupported.add(key) } } @@ -89,7 +99,15 @@ export function parsePolicyDocument(input: string | unknown): PolicyDocument { tagOwners: asObject(parsed.tagOwners) as Record, hosts: asObject(parsed.hosts) as Record, acls: asArray(parsed.acls), - ssh: Array.isArray(parsed.ssh) ? asArray(parsed.ssh) : undefined, + ssh: Array.isArray(parsed.ssh) ? asArray(parsed.ssh) : undefined, + grants: Array.isArray(parsed.grants) ? asArray(parsed.grants) : undefined, + nodeAttrs: Array.isArray(parsed.nodeAttrs) ? asArray(parsed.nodeAttrs) : undefined, + tests: Array.isArray(parsed.tests) ? asArray(parsed.tests) : undefined, + sshTests: Array.isArray(parsed.sshTests) ? asArray(parsed.sshTests) : undefined, + postures: Array.isArray(parsed.postures) ? asArray(parsed.postures) : undefined, + randomizeClientPort: typeof parsed.randomizeClientPort === 'boolean' + ? parsed.randomizeClientPort + : undefined, } return { @@ -112,6 +130,42 @@ export function mergeLegacyPolicyDocument(raw: JsonObject, legacy: LegacyPolicyS merged.ssh = clone(legacy.ssh) } + if (legacy.grants === undefined) { + delete merged.grants + } else { + merged.grants = clone(legacy.grants) + } + + if (legacy.nodeAttrs === undefined) { + delete merged.nodeAttrs + } else { + merged.nodeAttrs = clone(legacy.nodeAttrs) + } + + if (legacy.tests === undefined) { + delete merged.tests + } else { + merged.tests = clone(legacy.tests) + } + + if (legacy.sshTests === undefined) { + delete merged.sshTests + } else { + merged.sshTests = clone(legacy.sshTests) + } + + if (legacy.postures === undefined) { + delete merged.postures + } else { + merged.postures = clone(legacy.postures) + } + + if (legacy.randomizeClientPort === undefined) { + delete merged.randomizeClientPort + } else { + merged.randomizeClientPort = legacy.randomizeClientPort + } + return merged } diff --git a/src/lib/common/policy-types.ts b/src/lib/common/policy-types.ts new file mode 100644 index 0000000..8d93f73 --- /dev/null +++ b/src/lib/common/policy-types.ts @@ -0,0 +1,53 @@ +export type JsonObject = Record + +export type PolicyRuleMeta = { + name: string + open: boolean +} + +export type PolicyAclRule = { + '#ha-meta'?: PolicyRuleMeta + action: 'accept' + proto?: string + src: string[] + dst: string[] +} + +export type PolicySshRule = { + action: 'accept' + src: string[] + dst: string[] + users: string[] +} + +export type PolicyGrant = { + src: string[] + dst: string[] + ip?: string[] + app?: Record + via?: string[] + srcPosture?: string[] +} + +export type PolicyNodeAttr = { + target: string[] + attr: string[] +} + +export type PolicyTest = Record +export type PolicySshTest = Record +export type PolicyPosture = Record + +export type PolicySections = { + groups: Record + tagOwners: Record + hosts: Record + acls: PolicyAclRule[] + ssh?: PolicySshRule[] + grants?: PolicyGrant[] + nodeAttrs?: PolicyNodeAttr[] + tests?: PolicyTest[] + sshTests?: PolicySshTest[] + postures?: PolicyPosture[] + randomizeClientPort?: boolean +} diff --git a/src/routes/acls/+page.svelte b/src/routes/acls/+page.svelte index 92ff635..8159473 100644 --- a/src/routes/acls/+page.svelte +++ b/src/routes/acls/+page.svelte @@ -9,7 +9,8 @@ import RawMdiSecurity from '~icons/mdi/security'; import RawMdiTag from '~icons/mdi/tag'; - import { ACLBuilder, type ACL } from '$lib/common/acl.svelte'; + import type { ACL } from '$lib/common/acl.svelte'; + import { PolicyBuilder } from '$lib/common/policy-builder'; import { debug } from '$lib/common/debug'; import { getPolicy } from '$lib/common/api'; import { toastError } from '$lib/common/funcs'; @@ -26,7 +27,7 @@ const ToastStore = getToastStore() - let acl = $state(ACLBuilder.defaultACL()); + let acl = $state(PolicyBuilder.defaultACL()); let loading = $state(false) // Navigation tabs @@ -42,7 +43,7 @@ onMount(() => { getPolicy().then(policy => { - acl = ACLBuilder.fromPolicy(JWCC.parse(policy)) + acl = PolicyBuilder.fromPolicy(JWCC.parse(policy)) }).catch(reason => { debug("failed to get policy:", reason) toastError(`Unable to get policy from server.`, ToastStore, reason) @@ -51,12 +52,12 @@ - + {#if acl.hasUnsupportedPolicyFields()}
Compatibility warning
- This policy includes fields outside the legacy ACL editor model. Saving keeps those fields, but edits in legacy sections may not expose all advanced settings. + This policy includes fields outside the current editor model. Saving keeps those fields, but ACL tabs may not expose all advanced settings.
{/if} diff --git a/src/routes/acls/Config.svelte b/src/routes/acls/Config.svelte index c63c8f9..7764bf2 100644 --- a/src/routes/acls/Config.svelte +++ b/src/routes/acls/Config.svelte @@ -1,6 +1,8 @@
-
- -
- {#if title !== undefined} - {title} - {:else if childTitle !== undefined} - {@render childTitle()} - {/if} + {#if onclick !== undefined} + + {:else} +
+ +
+ {#if title !== undefined} + {title} + {:else if childTitle !== undefined} + {@render childTitle()} + {/if} +
+ +
+ {#if value !== undefined} + {value} + {:else if children !== undefined} + {@render children()} + {/if} +
- -
- {#if value !== undefined} - {value} - {:else if children !== undefined} - {@render children()} - {/if} -
-
+ {/if} {#if childBottom} {@render childBottom()} {/if} diff --git a/src/lib/cards/node/NodeExpiresAt.svelte b/src/lib/cards/node/NodeExpiresAt.svelte index a04b449..d44d1c8 100644 --- a/src/lib/cards/node/NodeExpiresAt.svelte +++ b/src/lib/cards/node/NodeExpiresAt.svelte @@ -14,11 +14,12 @@ let { node, loading = $bindable(false) }: NodeExpiresAtProps = $props() - let diff = $state(getTimeDifference(getTime(node.expiry))); + const getDiff = () => getTimeDifference(getTime(node.expiry)) + let diff = $state(getDiff()); onMount(() => { const interval = setInterval(() => { - diff = getTimeDifference(getTime(node.expiry)); + diff = getDiff(); }, 1000); return () => { diff --git a/src/lib/cards/node/NodeLastSeen.svelte b/src/lib/cards/node/NodeLastSeen.svelte index 8bc9e85..8c1542b 100644 --- a/src/lib/cards/node/NodeLastSeen.svelte +++ b/src/lib/cards/node/NodeLastSeen.svelte @@ -9,11 +9,12 @@ } let { node }: NodeLastSeenProps = $props() - let lastSeen = $state(getTimeDifferenceMessage(getTime(node.lastSeen))); + const getLastSeen = () => getTimeDifferenceMessage(getTime(node.lastSeen)) + let lastSeen = $state(getLastSeen()); onMount(() => { const interval = setInterval(() => { - lastSeen = getTimeDifferenceMessage(getTime(node.lastSeen)); + lastSeen = getLastSeen(); }, 1000); return () => { clearInterval(interval); diff --git a/src/lib/cards/preauth/PreAuthKeyDetails.svelte b/src/lib/cards/preauth/PreAuthKeyDetails.svelte index 5b78ad9..d603d07 100644 --- a/src/lib/cards/preauth/PreAuthKeyDetails.svelte +++ b/src/lib/cards/preauth/PreAuthKeyDetails.svelte @@ -9,7 +9,8 @@ } let { preAuthKey }: PreAuthKeyDetailsProps = $props() - let pakIsExpired = $state(isExpired(preAuthKey)) + const getPakIsExpired = () => isExpired(preAuthKey) + let pakIsExpired = $state(getPakIsExpired()) const ownershipLabel = $derived(preAuthKey.user ? 'User:' : 'Tags:') const ownershipValue = $derived(preAuthKey.user ? preAuthKey.user.name : preAuthKey.aclTags.join(', ')) @@ -19,7 +20,7 @@ onMount(()=>{ const interval = setInterval(() => { - pakIsExpired = isExpired(preAuthKey) + pakIsExpired = getPakIsExpired() }, 1000) return () => { diff --git a/src/lib/cards/user/UserListPreAuthKey.svelte b/src/lib/cards/user/UserListPreAuthKey.svelte index f941481..0e7c0a3 100644 --- a/src/lib/cards/user/UserListPreAuthKey.svelte +++ b/src/lib/cards/user/UserListPreAuthKey.svelte @@ -12,7 +12,8 @@ let { preAuthKey }: UserListPreAuthKeyProps = $props() const toastStore = getToastStore(); - let pakIsExpired = $state(isExpired(preAuthKey)) + const getPakIsExpired = () => isExpired(preAuthKey) + let pakIsExpired = $state(getPakIsExpired()) function isExpired(preAuthKey: PreAuthKey): boolean { return new Date() > new Date(preAuthKey.expiration); @@ -20,7 +21,7 @@ onMount(()=>{ const interval = setInterval(() => { - pakIsExpired = isExpired(preAuthKey) + pakIsExpired = getPakIsExpired() }, 1000) return () => { diff --git a/src/lib/parts/NodeTagsIcon.svelte b/src/lib/parts/NodeTagsIcon.svelte index a64e7e3..42cf10e 100644 --- a/src/lib/parts/NodeTagsIcon.svelte +++ b/src/lib/parts/NodeTagsIcon.svelte @@ -10,11 +10,11 @@ }; let { tags, id }: NodeTagsIconProps = $props(); - const popupHover: PopupSettings = { + const popupHover = $derived({ event: 'hover', target: `tags-popup-${id}`, placement: 'bottom', - }; + }); {#if tags.length > 0} @@ -27,6 +27,6 @@ {/each}
-
+
{/if} diff --git a/src/routes/deploy/DeployCheck.svelte b/src/routes/deploy/DeployCheck.svelte index 4c8a436..04e19b5 100644 --- a/src/routes/deploy/DeployCheck.svelte +++ b/src/routes/deploy/DeployCheck.svelte @@ -14,16 +14,16 @@ let { name, help, checked = $bindable(), children = undefined }: DeployCheckTypes = $props() - const popupId = xxHash32(name).toString(); + const popupId = $derived(xxHash32(name).toString()); let popupShow = $state(false); let timerInfo: ReturnType; - const popupInfo: PopupSettings = { + const popupInfo = $derived({ event: 'hover', target: 'popupHover-' + popupId, placement: 'top', - }; + }); function handleMouseEnter() { timerInfo = setTimeout(() => { From ab3d624d1ee33a3d8418942e2eca8ea1e83bed8a Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Fri, 3 Jul 2026 12:37:55 +0100 Subject: [PATCH 4/5] fix: update version to 0.29.2/update1 in debug module and add workplan to .gitignore --- .gitignore | 2 ++ src/lib/common/debug.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c807dd6..005ab39 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,5 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +*workplan* \ No newline at end of file diff --git a/src/lib/common/debug.ts b/src/lib/common/debug.ts index 08d135a..30247e7 100644 --- a/src/lib/common/debug.ts +++ b/src/lib/common/debug.ts @@ -1,6 +1,6 @@ import { App } from "$lib/States.svelte"; -export const version = '0.29.2/update0'; +export const version = '0.29.2/update1'; export function debug(...data: unknown[]) { // output if console debugging is enabled From 7a5e9c4322a9ff121b495c2a1e99f55831f9803a Mon Sep 17 00:00:00 2001 From: CJFWeatherhead Date: Fri, 3 Jul 2026 12:49:51 +0100 Subject: [PATCH 5/5] policy: phase 1.5 split domain editor and persistence --- src/lib/common/acl.svelte.ts | 305 ++++--------------------- src/lib/common/api/modify.ts | 7 +- src/lib/common/policy-builder.ts | 6 +- src/lib/common/policy-domain.test.ts | 108 +++++++++ src/lib/common/policy-domain.ts | 301 ++++++++++++++++++++++++ src/lib/common/policy-editor.svelte.ts | 15 ++ src/lib/common/policy-persistence.ts | 13 ++ src/routes/acls/+page.svelte | 4 +- src/routes/acls/Config.svelte | 23 +- src/routes/acls/Groups.svelte | 22 +- src/routes/acls/Hosts.svelte | 22 +- src/routes/acls/Policies.svelte | 24 +- src/routes/acls/SshRules.svelte | 24 +- src/routes/acls/TagOwners.svelte | 22 +- 14 files changed, 605 insertions(+), 291 deletions(-) create mode 100644 src/lib/common/policy-domain.test.ts create mode 100644 src/lib/common/policy-domain.ts create mode 100644 src/lib/common/policy-editor.svelte.ts create mode 100644 src/lib/common/policy-persistence.ts diff --git a/src/lib/common/acl.svelte.ts b/src/lib/common/acl.svelte.ts index ec090a1..dfc1b5a 100644 --- a/src/lib/common/acl.svelte.ts +++ b/src/lib/common/acl.svelte.ts @@ -1,10 +1,31 @@ import JWCC from 'json5' -import { isValidCIDR, isValidIP, toastError, toastSuccess } from "$lib/common/funcs" -import { setPolicy } from './api' -import type { ToastStore } from '@skeletonlabs/skeleton' -import { debug } from './debug' import { parsePolicyDocument, serialisePolicyDocument, type LegacyPolicySection } from './policy-document' import type { PolicyGrant, PolicyNodeAttr, PolicyPosture, PolicySshTest, PolicyTest } from './policy-types' +import { + addPrefix, + createGroupInPolicy, + createHostInPolicy, + createTagInPolicy, + deleteGroupFromPolicy, + deleteHostFromPolicy, + deleteTagFromPolicy, + getPolicyDstHost, + getPolicyDstPorts, + getPrefix, + normalizePrefix, + renameGroupInPolicy, + renameHostInPolicy, + renameTagInPolicy, + setGroupMembersInPolicy, + setHostInPolicy, + setTagOwnersInPolicy, + stripPrefix, + validateGroupName, + validateHostName, + validateHostValue, + validateTagName, + type PrefixType, +} from './policy-domain' export type TagOwners = string[] export type TagOwnersTyped = { users: string[], groups: string[], tags: string[] } @@ -64,17 +85,6 @@ export type ACL = { randomizeClientPort?: boolean, } -export type PrefixType = "group" | "tag" - -const Prefixes: Record = { - "group": "group:", - "tag": "tag:", -} - -const RegexGroupName = /^[a-z0-9-\.]+$/ -const RegexTagName = /^[^\s:]+$/ -const RegexHostName = /^[a-z0-9-\.]+$/ - export class ACLBuilder implements ACL { #policyRaw: Record | undefined #unsupportedPolicyFields: string[] @@ -241,33 +251,20 @@ export class ACLBuilder implements ACL { } private static getPrefix(name: string): PrefixType | null { - for (const [prefixType, prefix] of Object.entries(Prefixes)) { - if (name.startsWith(prefix)) { - return prefixType as PrefixType - } - } - return null + return getPrefix(name) } // remove the group: prefix if it exists private static stripPrefix(name: string): string { - const nameLower = name.toLowerCase() - for (const prefix of Object.values(Prefixes)) { - if (nameLower.startsWith(prefix)) { - return name.substring(prefix.length) - } - } - return name + return stripPrefix(name) } private static addPrefix(name: string, type: PrefixType): string { - return Prefixes[type] + name + return addPrefix(name, type) } private static normalizePrefix(name: string, type: PrefixType): { prefixed: string, stripped: string } { - const stripped = this.stripPrefix(name) - const prefixed = this.addPrefix(stripped, type) - return { prefixed, stripped } + return normalizePrefix(name, type) } static normalizeTag(tag: string): {prefixed: string, stripped: string} { @@ -280,42 +277,21 @@ export class ACLBuilder implements ACL { // throws an error if the name is invalid, otherwise returns the normalized group name static validateGroupName(name: string): string { - name = this.stripPrefix(name) - if (name.toLowerCase() !== name) { - throw new Error("Group name must be lowercase") - } - if (!RegexGroupName.test(name)) { - throw new Error("Group name is limited to: lowercase alphabet, digits, dashes, and periods") - } - return name + return validateGroupName(name) } // tag names can contain anything but spaces static validateTagName(name: string): string { - name = this.stripPrefix(name) - if (!RegexTagName.test(name)) { - throw new Error("Tag name must contain no spaces") - } - return name + return validateTagName(name) } // host names can contain anything but spaces static validateHostName(name: string): string { - name = name.toLowerCase() - if (!RegexHostName.test(name)) { - throw new Error("Host name is limited to: lowercase alphabet, digits, dashes, and periods") - } - return name + return validateHostName(name) } static validateHostValue(value: string): string { - if(isValidIP(value)) { - return value - } - if(isValidCIDR(value)) { - return value - } - throw new Error("Invalid Host IP or CIDR") + return validateHostValue(value) } // deep clone of current ACL @@ -338,10 +314,7 @@ export class ACLBuilder implements ACL { createHost(name: string, cidr: string) { - if(this.getHostCIDR(name) !== undefined) { - throw new Error(`host "${name}" already exists`) - } - this.setHost(name, cidr) + createHostInPolicy(this, name, cidr) } getHostCIDR(name: string): string | undefined { @@ -349,31 +322,11 @@ export class ACLBuilder implements ACL { } setHost(name: string, value: string) { - name = ACLBuilder.validateHostName(name) - value = ACLBuilder.validateHostValue(value) - this.hosts[name] = value + setHostInPolicy(this, name, value) } renameHost(nameOld: string, nameNew: string) { - nameOld = ACLBuilder.validateHostName(nameOld) - nameNew = ACLBuilder.validateHostName(nameNew) - if (this.hosts[nameOld] === undefined) { - throw new Error(`Host '${nameOld}' does not exist`) - } - if (this.hosts[nameNew] !== undefined) { - throw new Error(`Host '${nameNew}' already exists`) - } - - const hosts: AclHosts = {} - Object.entries(this.hosts).forEach(([name, value])=>{ - hosts[name === nameOld ? nameNew : name] = value - }) - this.hosts = hosts - - this.acls.forEach(acl => { - acl.src = acl.src.map(src => (src === nameOld ? nameNew : src)) - acl.dst = acl.dst.map(dst => (ACLBuilder.getPolicyDstHost(dst) === nameOld ? nameNew + ":" + ACLBuilder.getPolicyDstPorts(dst) : dst)) - }) + renameHostInPolicy(this, nameOld, nameNew) } getHostNames(): string[] { @@ -385,25 +338,7 @@ export class ACLBuilder implements ACL { } deleteHost(name: string) { - if (this.hosts[name] === undefined) { - throw new Error(`Host '${name}' doesn't exist`) - } - - delete this.hosts[name] - - // delete host from ACLs - for (const acl of this.acls) { - acl.src = acl.src.filter(s => s !== name) - acl.dst = acl.dst.filter(d => d !== name) - } - - // remove group from SSH - if (this.ssh !== undefined){ - for (const ssh of this.ssh) { - ssh.src = ssh.src.filter(s => s !== name) - ssh.dst = ssh.dst.filter(d => d !== name) - } - } + deleteHostFromPolicy(this, name) } @@ -421,47 +356,15 @@ export class ACLBuilder implements ACL { */ createTag(name: string) { - name = ACLBuilder.validateTagName(name) - const { prefixed } = ACLBuilder.normalizePrefix(name, 'tag') - this.tagOwners[prefixed] = [] + createTagInPolicy(this, name) } renameTag(nameOld: string, nameNew: string) { - nameNew = ACLBuilder.validateTagName(nameNew) - const { prefixed: prefixedNew } = ACLBuilder.normalizePrefix(nameNew, 'tag') - const { stripped: strippedOld, prefixed: prefixedOld } = ACLBuilder.normalizePrefix(nameOld, 'tag') - - // if names are equal, don't do anything - if (prefixedNew === prefixedOld) { - return - } - - if (this.tagOwners[prefixedOld] === undefined) { - throw new Error(`Tag '${strippedOld}' doesn't exist`) - } - - const tagOwners: AclTagOwners = {} - Object.entries(this.tagOwners).forEach(([name, owners]) => { - tagOwners[name === prefixedOld ? prefixedNew : name] = owners - }) - this.tagOwners = tagOwners - - this.acls.forEach(acl => { - acl.src = acl.src.map(src => (src === prefixedOld ? prefixedNew : src)) - acl.dst = acl.dst.map(dst => (ACLBuilder.getPolicyDstHost(dst) === prefixedOld ? prefixedNew + ":" + ACLBuilder.getPolicyDstPorts(dst) : dst)) - }) - if (this.ssh !== undefined) { - for (const rule of this.ssh) { - rule.src = rule.src.map(src => (src === prefixedOld ? prefixedNew : src)) - rule.dst = rule.dst.map(dst => (dst === prefixedOld ? prefixedNew : dst)) - } - } + renameTagInPolicy(this, nameOld, nameNew) } setTagOwners(name: string, owners: TagOwners) { - const { prefixed } = ACLBuilder.normalizePrefix(name, 'tag') - const ownersAll = [...owners] - this.tagOwners[prefixed] = ownersAll + setTagOwnersInPolicy(this, name, owners) } getTagNames(withPrefix: boolean = false): string[] { @@ -516,27 +419,7 @@ export class ACLBuilder implements ACL { } deleteTag(name: string) { - const { stripped, prefixed } = ACLBuilder.normalizePrefix(name, 'tag') - - if (this.tagOwners[prefixed] === undefined) { - throw new Error(`Tag '${stripped}' doesn't exist within the ACL`) - } - - // remove tag from ACLs - for (const acl of this.acls){ - acl.src = acl.src.filter(s => s !== prefixed); - acl.dst = acl.dst.filter(d => d !== prefixed); - } - - // remove tag from SSH - if (this.ssh !== undefined){ - for (const ssh of this.ssh) { - ssh.src = ssh.src.filter(s => s !== prefixed) - ssh.dst = ssh.dst.filter(d => d !== prefixed) - } - } - - delete this.tagOwners[prefixed] + deleteTagFromPolicy(this, name) } @@ -552,58 +435,15 @@ export class ACLBuilder implements ACL { * deleteGroup(name) */ createGroup(name: string) { - name = ACLBuilder.validateGroupName(name) - const { stripped, prefixed } = ACLBuilder.normalizePrefix(name, 'group') - - if (this.groups[prefixed] !== undefined) { - throw new Error(`Group '${stripped}' already exists`) - } - - this.groups[prefixed] = [] + createGroupInPolicy(this, name) } renameGroup(nameOld: string, nameNew: string) { - nameNew = ACLBuilder.validateGroupName(nameNew) - const { prefixed: prefixedNew } = ACLBuilder.normalizePrefix(nameNew, 'group') - const { stripped: strippedOld, prefixed: prefixedOld } = ACLBuilder.normalizePrefix(nameOld, 'group') - - // if names are equal, don't do anything - if (prefixedNew === prefixedOld) { - return - } - - if (this.groups[prefixedOld] === undefined) { - throw new Error(`Group '${strippedOld}' doesn't exist`) - } - - const groups: AclGroups = {} - Object.entries(this.groups).forEach(([name, members]) => { - groups[name === prefixedOld ? prefixedNew : name] = members - }) - this.groups = groups - - this.acls.forEach(acl => { - acl.src = acl.src.map(src => (src === prefixedOld ? prefixedNew : src)) - acl.dst = acl.dst.map(dst => (ACLBuilder.getPolicyDstHost(dst) === prefixedOld ? prefixedNew + ":" + ACLBuilder.getPolicyDstPorts(dst) : dst)) - }) - - for (const key in this.tagOwners) { - this.tagOwners[key] = this.tagOwners[key].map(owner => - owner === prefixedOld ? prefixedNew : owner - ) - } - if (this.ssh !== undefined) { - for (const rule of this.ssh) { - rule.src = rule.src.map(src => (src === prefixedOld ? prefixedNew : src)) - rule.dst = rule.dst.map(src => (src === prefixedOld ? prefixedNew : src)) - } - } + renameGroupInPolicy(this, nameOld, nameNew) } setGroupMembers(name: string, members: string[]) { - const { prefixed } = ACLBuilder.normalizePrefix(name, 'group') - - this.groups[prefixed] = [...members] + setGroupMembersInPolicy(this, name, members) } getGroupByName(name: string): string[] { @@ -631,32 +471,7 @@ export class ACLBuilder implements ACL { } deleteGroup(name: string) { - const { stripped, prefixed } = ACLBuilder.normalizePrefix(name, 'group') - - // verify group's existence - if (this.groups[prefixed] === undefined) { - throw new Error(`Group '${stripped}' doesn't exist`) - } - - // remove group from tag owners - for (const tag of Object.keys(this.tagOwners)) { - this.tagOwners[tag] = this.tagOwners[tag].filter(t => t !== prefixed) - } - - // remove group from ACLs - for (const acl of this.acls) { - acl.src = acl.src.filter(s => s !== prefixed) - acl.dst = acl.dst.filter(d => d !== prefixed) - } - - // remove group from SSH policies - if (this.ssh !== undefined){ - for (const ssh of this.ssh) { - ssh.src = ssh.src.filter(s => s !== prefixed) - } - } - - delete this.groups[prefixed] + deleteGroupFromPolicy(this, name) } /* * POLICIES: @@ -672,13 +487,11 @@ export class ACLBuilder implements ACL { */ public static getPolicyDstHost(dst: string): string { - const i = dst.lastIndexOf(':') - return i < 0 ? dst : dst.substring(0, i) + return getPolicyDstHost(dst) } public static getPolicyDstPorts(dst: string): string { - const i = dst.lastIndexOf(':') - return i < 0 ? dst : dst.substring(i+1, dst.length) + return getPolicyDstPorts(dst) } @@ -850,27 +663,3 @@ export class ACLBuilder implements ACL { } } } - -export async function saveConfig(acl: ACLBuilder, ToastStore: ToastStore, loading?: {setLoadingTrue: ()=>void, setLoadingFalse: ()=>void}) { - if(loading !== undefined){ - loading.setLoadingTrue() - } - //loading = true - try { - await setPolicy(acl) - if(ToastStore !== undefined){ - toastSuccess('Saved ACL Configuration', ToastStore) - } - } catch(err) { - if (err instanceof Error){ - if(ToastStore !== undefined){ - toastError('', ToastStore, err) - } - } - debug(err) - } finally { - if(loading !== undefined){ - loading.setLoadingFalse() - } - } -} diff --git a/src/lib/common/api/modify.ts b/src/lib/common/api/modify.ts index b09db52..c907718 100644 --- a/src/lib/common/api/modify.ts +++ b/src/lib/common/api/modify.ts @@ -8,13 +8,16 @@ import type { } from '$lib/common/types'; import { debug } from '../debug'; import { apiPost, apiPut } from './base'; -import type { ACLBuilder } from '../acl.svelte'; import { API_URL_NODE, API_URL_POLICY, API_URL_PREAUTHKEY, API_URL_USER } from './url'; import { createApiKey } from './create'; import { expireApiKey } from './delete'; import { App } from '$lib/States.svelte'; import { setsEqual } from '../funcs'; +type PolicySerializable = { + JSON: (space?: number) => string; +}; + export async function renameUser(u: User, nameNew: string): Promise { const path = `${API_URL_USER}/${u.id}/rename/${nameNew}`; const { user } = await apiPost(path, undefined); @@ -88,7 +91,7 @@ export async function disableRoutes(node: Node, ...routes: string[]): Promise(path, {"policy": acl.JSON(4)}) } diff --git a/src/lib/common/policy-builder.ts b/src/lib/common/policy-builder.ts index fbe814e..6fa469a 100644 --- a/src/lib/common/policy-builder.ts +++ b/src/lib/common/policy-builder.ts @@ -1,8 +1,8 @@ -import { ACLBuilder } from './acl.svelte' +import { PolicyEditor } from './policy-editor.svelte' // Bridge export for the modern policy model. // The underlying implementation remains ACL-compatible while supporting // additional 0.29.x policy sections (grants, nodeAttrs, tests, sshTests, // postures, randomizeClientPort). -export const PolicyBuilder = ACLBuilder -export type PolicyBuilder = ACLBuilder +export const PolicyBuilder = PolicyEditor +export type PolicyBuilder = PolicyEditor diff --git a/src/lib/common/policy-domain.test.ts b/src/lib/common/policy-domain.test.ts new file mode 100644 index 0000000..e1b3a6d --- /dev/null +++ b/src/lib/common/policy-domain.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { + createGroupInPolicy, + createHostInPolicy, + createTagInPolicy, + deleteGroupFromPolicy, + deleteHostFromPolicy, + deleteTagFromPolicy, + renameGroupInPolicy, + renameHostInPolicy, + renameTagInPolicy, + setGroupMembersInPolicy, + setTagOwnersInPolicy, + type PolicyDomainState, +} from '$lib/common/policy-domain' + +function makeState(): PolicyDomainState { + return { + groups: { 'group:admins': ['alice'] }, + tagOwners: { 'tag:prod': ['group:admins'] }, + hosts: { db: '100.64.0.10' }, + acls: [ + { + src: ['group:admins', 'db', 'tag:prod'], + dst: ['db:5432', 'tag:prod:443', 'group:admins:*'], + }, + ], + ssh: [ + { + src: ['group:admins', 'db', 'tag:prod'], + dst: ['db', 'tag:prod'], + }, + ], + } +} + +describe('policy-domain extracted mutations', () => { + it('renames host and updates ACL/SSH references', () => { + const state = makeState() + renameHostInPolicy(state, 'db', 'database') + + expect(state.hosts).toEqual({ database: '100.64.0.10' }) + expect(state.acls[0].src).toContain('database') + expect(state.acls[0].dst).toContain('database:5432') + // Existing ACLBuilder behaviour keeps SSH references unchanged on host rename. + expect(state.ssh?.[0].src).toContain('db') + expect(state.ssh?.[0].dst).toContain('db') + }) + + it('renames tag and updates ACL/SSH references', () => { + const state = makeState() + renameTagInPolicy(state, 'prod', 'production') + + expect(state.tagOwners['tag:production']).toEqual(['group:admins']) + expect(state.tagOwners['tag:prod']).toBeUndefined() + expect(state.acls[0].src).toContain('tag:production') + expect(state.acls[0].dst).toContain('tag:production:443') + expect(state.ssh?.[0].src).toContain('tag:production') + expect(state.ssh?.[0].dst).toContain('tag:production') + }) + + it('renames group and updates references in ACL, tagOwners, and SSH', () => { + const state = makeState() + renameGroupInPolicy(state, 'admins', 'ops') + + expect(state.groups['group:ops']).toEqual(['alice']) + expect(state.groups['group:admins']).toBeUndefined() + expect(state.tagOwners['tag:prod']).toContain('group:ops') + expect(state.acls[0].src).toContain('group:ops') + expect(state.acls[0].dst).toContain('group:ops:*') + expect(state.ssh?.[0].src).toContain('group:ops') + }) + + it('deletes group/tag/host and removes linked ACL/SSH references', () => { + const state = makeState() + deleteGroupFromPolicy(state, 'admins') + deleteTagFromPolicy(state, 'prod') + deleteHostFromPolicy(state, 'db') + + expect(state.groups).toEqual({}) + expect(state.tagOwners).toEqual({}) + expect(state.hosts).toEqual({}) + expect(state.acls[0].src).toEqual([]) + // Existing ACLBuilder behaviour only removes exact destination matches. + expect(state.acls[0].dst).toEqual(['db:5432', 'tag:prod:443', 'group:admins:*']) + expect(state.ssh?.[0].src).toEqual([]) + expect(state.ssh?.[0].dst).toEqual([]) + }) + + it('creates and sets group/tag data with normalised keys', () => { + const state: PolicyDomainState = { + groups: {}, + tagOwners: {}, + hosts: {}, + acls: [], + } + + createGroupInPolicy(state, 'new-team') + setGroupMembersInPolicy(state, 'new-team', ['bob']) + createTagInPolicy(state, 'backend') + setTagOwnersInPolicy(state, 'backend', ['group:new-team']) + createHostInPolicy(state, 'api', '100.64.0.20') + + expect(state.groups['group:new-team']).toEqual(['bob']) + expect(state.tagOwners['tag:backend']).toEqual(['group:new-team']) + expect(state.hosts.api).toEqual('100.64.0.20') + }) +}) diff --git a/src/lib/common/policy-domain.ts b/src/lib/common/policy-domain.ts new file mode 100644 index 0000000..0f4ceab --- /dev/null +++ b/src/lib/common/policy-domain.ts @@ -0,0 +1,301 @@ +import { isValidCIDR, isValidIP } from './funcs' + +export type PrefixType = 'group' | 'tag' + +const PREFIXES: Record = { + group: 'group:', + tag: 'tag:', +} + +const REGEX_GROUP_NAME = /^[a-z0-9-\.]+$/ +const REGEX_TAG_NAME = /^[^\s:]+$/ +const REGEX_HOST_NAME = /^[a-z0-9-\.]+$/ + +export type PolicyAclReferenceRule = { + src: string[] + dst: string[] +} + +export type PolicySshReferenceRule = { + src: string[] + dst: string[] +} + +export type PolicyDomainState = { + groups: Record + tagOwners: Record + hosts: Record + acls: PolicyAclReferenceRule[] + ssh?: PolicySshReferenceRule[] +} + +export function getPrefix(name: string): PrefixType | null { + for (const [prefixType, prefix] of Object.entries(PREFIXES)) { + if (name.startsWith(prefix)) { + return prefixType as PrefixType + } + } + return null +} + +export function stripPrefix(name: string): string { + const lower = name.toLowerCase() + for (const prefix of Object.values(PREFIXES)) { + if (lower.startsWith(prefix)) { + return name.substring(prefix.length) + } + } + return name +} + +export function addPrefix(name: string, type: PrefixType): string { + return PREFIXES[type] + name +} + +export function normalizePrefix(name: string, type: PrefixType): { prefixed: string, stripped: string } { + const stripped = stripPrefix(name) + const prefixed = addPrefix(stripped, type) + return { prefixed, stripped } +} + +export function validateGroupName(name: string): string { + name = stripPrefix(name) + if (name.toLowerCase() !== name) { + throw new Error('Group name must be lowercase') + } + if (!REGEX_GROUP_NAME.test(name)) { + throw new Error('Group name is limited to: lowercase alphabet, digits, dashes, and periods') + } + return name +} + +export function validateTagName(name: string): string { + name = stripPrefix(name) + if (!REGEX_TAG_NAME.test(name)) { + throw new Error('Tag name must contain no spaces') + } + return name +} + +export function validateHostName(name: string): string { + name = name.toLowerCase() + if (!REGEX_HOST_NAME.test(name)) { + throw new Error('Host name is limited to: lowercase alphabet, digits, dashes, and periods') + } + return name +} + +export function validateHostValue(value: string): string { + if (isValidIP(value) || isValidCIDR(value)) { + return value + } + throw new Error('Invalid Host IP or CIDR') +} + +export function getPolicyDstHost(dst: string): string { + const i = dst.lastIndexOf(':') + return i < 0 ? dst : dst.substring(0, i) +} + +export function getPolicyDstPorts(dst: string): string { + const i = dst.lastIndexOf(':') + return i < 0 ? dst : dst.substring(i + 1, dst.length) +} + +export function createHostInPolicy(state: PolicyDomainState, name: string, cidr: string) { + if (state.hosts[name] !== undefined) { + throw new Error(`host "${name}" already exists`) + } + setHostInPolicy(state, name, cidr) +} + +export function setHostInPolicy(state: PolicyDomainState, name: string, value: string) { + name = validateHostName(name) + value = validateHostValue(value) + state.hosts[name] = value +} + +export function renameHostInPolicy(state: PolicyDomainState, nameOld: string, nameNew: string) { + nameOld = validateHostName(nameOld) + nameNew = validateHostName(nameNew) + + if (state.hosts[nameOld] === undefined) { + throw new Error(`Host '${nameOld}' does not exist`) + } + if (state.hosts[nameNew] !== undefined) { + throw new Error(`Host '${nameNew}' already exists`) + } + + const hosts: Record = {} + Object.entries(state.hosts).forEach(([name, value]) => { + hosts[name === nameOld ? nameNew : name] = value + }) + state.hosts = hosts + + state.acls.forEach((acl) => { + acl.src = acl.src.map((src) => (src === nameOld ? nameNew : src)) + acl.dst = acl.dst.map((dst) => (getPolicyDstHost(dst) === nameOld ? `${nameNew}:${getPolicyDstPorts(dst)}` : dst)) + }) +} + +export function deleteHostFromPolicy(state: PolicyDomainState, name: string) { + if (state.hosts[name] === undefined) { + throw new Error(`Host '${name}' doesn't exist`) + } + + delete state.hosts[name] + + for (const acl of state.acls) { + acl.src = acl.src.filter((s) => s !== name) + acl.dst = acl.dst.filter((d) => d !== name) + } + + if (state.ssh !== undefined) { + for (const ssh of state.ssh) { + ssh.src = ssh.src.filter((s) => s !== name) + ssh.dst = ssh.dst.filter((d) => d !== name) + } + } +} + +export function createTagInPolicy(state: PolicyDomainState, name: string) { + name = validateTagName(name) + const { prefixed } = normalizePrefix(name, 'tag') + state.tagOwners[prefixed] = [] +} + +export function renameTagInPolicy(state: PolicyDomainState, nameOld: string, nameNew: string) { + nameNew = validateTagName(nameNew) + const { prefixed: prefixedNew } = normalizePrefix(nameNew, 'tag') + const { stripped: strippedOld, prefixed: prefixedOld } = normalizePrefix(nameOld, 'tag') + + if (prefixedNew === prefixedOld) { + return + } + + if (state.tagOwners[prefixedOld] === undefined) { + throw new Error(`Tag '${strippedOld}' doesn't exist`) + } + + const tagOwners: Record = {} + Object.entries(state.tagOwners).forEach(([name, owners]) => { + tagOwners[name === prefixedOld ? prefixedNew : name] = owners + }) + state.tagOwners = tagOwners + + state.acls.forEach((acl) => { + acl.src = acl.src.map((src) => (src === prefixedOld ? prefixedNew : src)) + acl.dst = acl.dst.map((dst) => (getPolicyDstHost(dst) === prefixedOld ? `${prefixedNew}:${getPolicyDstPorts(dst)}` : dst)) + }) + + if (state.ssh !== undefined) { + for (const rule of state.ssh) { + rule.src = rule.src.map((src) => (src === prefixedOld ? prefixedNew : src)) + rule.dst = rule.dst.map((dst) => (dst === prefixedOld ? prefixedNew : dst)) + } + } +} + +export function setTagOwnersInPolicy(state: PolicyDomainState, name: string, owners: string[]) { + const { prefixed } = normalizePrefix(name, 'tag') + state.tagOwners[prefixed] = [...owners] +} + +export function deleteTagFromPolicy(state: PolicyDomainState, name: string) { + const { stripped, prefixed } = normalizePrefix(name, 'tag') + + if (state.tagOwners[prefixed] === undefined) { + throw new Error(`Tag '${stripped}' doesn't exist within the ACL`) + } + + for (const acl of state.acls) { + acl.src = acl.src.filter((s) => s !== prefixed) + acl.dst = acl.dst.filter((d) => d !== prefixed) + } + + if (state.ssh !== undefined) { + for (const ssh of state.ssh) { + ssh.src = ssh.src.filter((s) => s !== prefixed) + ssh.dst = ssh.dst.filter((d) => d !== prefixed) + } + } + + delete state.tagOwners[prefixed] +} + +export function createGroupInPolicy(state: PolicyDomainState, name: string) { + name = validateGroupName(name) + const { stripped, prefixed } = normalizePrefix(name, 'group') + + if (state.groups[prefixed] !== undefined) { + throw new Error(`Group '${stripped}' already exists`) + } + + state.groups[prefixed] = [] +} + +export function renameGroupInPolicy(state: PolicyDomainState, nameOld: string, nameNew: string) { + nameNew = validateGroupName(nameNew) + const { prefixed: prefixedNew } = normalizePrefix(nameNew, 'group') + const { stripped: strippedOld, prefixed: prefixedOld } = normalizePrefix(nameOld, 'group') + + if (prefixedNew === prefixedOld) { + return + } + + if (state.groups[prefixedOld] === undefined) { + throw new Error(`Group '${strippedOld}' doesn't exist`) + } + + const groups: Record = {} + Object.entries(state.groups).forEach(([name, members]) => { + groups[name === prefixedOld ? prefixedNew : name] = members + }) + state.groups = groups + + state.acls.forEach((acl) => { + acl.src = acl.src.map((src) => (src === prefixedOld ? prefixedNew : src)) + acl.dst = acl.dst.map((dst) => (getPolicyDstHost(dst) === prefixedOld ? `${prefixedNew}:${getPolicyDstPorts(dst)}` : dst)) + }) + + for (const key in state.tagOwners) { + state.tagOwners[key] = state.tagOwners[key].map((owner) => owner === prefixedOld ? prefixedNew : owner) + } + + if (state.ssh !== undefined) { + for (const rule of state.ssh) { + rule.src = rule.src.map((src) => (src === prefixedOld ? prefixedNew : src)) + rule.dst = rule.dst.map((src) => (src === prefixedOld ? prefixedNew : src)) + } + } +} + +export function setGroupMembersInPolicy(state: PolicyDomainState, name: string, members: string[]) { + const { prefixed } = normalizePrefix(name, 'group') + state.groups[prefixed] = [...members] +} + +export function deleteGroupFromPolicy(state: PolicyDomainState, name: string) { + const { stripped, prefixed } = normalizePrefix(name, 'group') + + if (state.groups[prefixed] === undefined) { + throw new Error(`Group '${stripped}' doesn't exist`) + } + + for (const tag of Object.keys(state.tagOwners)) { + state.tagOwners[tag] = state.tagOwners[tag].filter((t) => t !== prefixed) + } + + for (const acl of state.acls) { + acl.src = acl.src.filter((s) => s !== prefixed) + acl.dst = acl.dst.filter((d) => d !== prefixed) + } + + if (state.ssh !== undefined) { + for (const ssh of state.ssh) { + ssh.src = ssh.src.filter((s) => s !== prefixed) + } + } + + delete state.groups[prefixed] +} \ No newline at end of file diff --git a/src/lib/common/policy-editor.svelte.ts b/src/lib/common/policy-editor.svelte.ts new file mode 100644 index 0000000..58ab2ee --- /dev/null +++ b/src/lib/common/policy-editor.svelte.ts @@ -0,0 +1,15 @@ +import { ACLBuilder } from './acl.svelte' + +export class PolicyEditor extends ACLBuilder { + static fromPolicy(policy: Parameters[0]): PolicyEditor { + return ACLBuilder.fromPolicy(policy) as PolicyEditor + } + + static defaultPolicy(): PolicyEditor { + return ACLBuilder.defaultACL() as PolicyEditor + } + + static emptyPolicy(): PolicyEditor { + return ACLBuilder.emptyACL() as PolicyEditor + } +} diff --git a/src/lib/common/policy-persistence.ts b/src/lib/common/policy-persistence.ts new file mode 100644 index 0000000..304c628 --- /dev/null +++ b/src/lib/common/policy-persistence.ts @@ -0,0 +1,13 @@ +import { getPolicy, setPolicy } from './api' + +export type PolicySerializable = { + JSON: (space?: number) => string +} + +export async function loadPolicyDocumentText(): Promise { + return await getPolicy() +} + +export async function savePolicyDocument(policy: PolicySerializable): Promise { + await setPolicy(policy) +} diff --git a/src/routes/acls/+page.svelte b/src/routes/acls/+page.svelte index 8159473..1361512 100644 --- a/src/routes/acls/+page.svelte +++ b/src/routes/acls/+page.svelte @@ -12,7 +12,7 @@ import type { ACL } from '$lib/common/acl.svelte'; import { PolicyBuilder } from '$lib/common/policy-builder'; import { debug } from '$lib/common/debug'; - import { getPolicy } from '$lib/common/api'; + import { loadPolicyDocumentText } from '$lib/common/policy-persistence'; import { toastError } from '$lib/common/funcs'; import Page from '$lib/page/Page.svelte'; import PageHeader from '$lib/page/PageHeader.svelte'; @@ -42,7 +42,7 @@ ]; onMount(() => { - getPolicy().then(policy => { + loadPolicyDocumentText().then(policy => { acl = PolicyBuilder.fromPolicy(JWCC.parse(policy)) }).catch(reason => { debug("failed to get policy:", reason) diff --git a/src/routes/acls/Config.svelte b/src/routes/acls/Config.svelte index 7764bf2..86e6ead 100644 --- a/src/routes/acls/Config.svelte +++ b/src/routes/acls/Config.svelte @@ -1,11 +1,11 @@
-