diff --git a/app/routes/acls/components/visual-editor.tsx b/app/routes/acls/components/visual-editor.tsx
new file mode 100644
index 00000000..2cfa4475
--- /dev/null
+++ b/app/routes/acls/components/visual-editor.tsx
@@ -0,0 +1,556 @@
+import { Pencil, Plus, Trash2 } from "lucide-react";
+import type { ReactNode } from "react";
+import { useState } from "react";
+
+import Button from "~/components/button";
+import Chip from "~/components/chip";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import TableList from "~/components/table-list";
+import {
+ type AclPolicy,
+ type AclRule,
+ type SshRule,
+ addAclRule,
+ addSshRule,
+ groupKey,
+ parsePolicy,
+ removeAclRule,
+ removeGroup,
+ removeHost,
+ removeSshRule,
+ removeTagOwner,
+ setGroup,
+ setHost,
+ setTagOwner,
+ stringifyPolicy,
+ tagKey,
+ updateAclRule,
+ updateSshRule,
+} from "~/utils/acl-editor";
+
+function split(v: string): string[] {
+ return v
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+function join(v: string[]): string {
+ return v.join(", ");
+}
+
+function Tags({ values }: { values: string[] }) {
+ return (
+
+ {values.map((v) => (
+
+ ))}
+
+ );
+}
+
+type ArrayDialogState =
+ | { mode: "closed" }
+ | { mode: "add" }
+ | { mode: "edit"; index: number }
+ | { mode: "delete"; index: number };
+
+interface ArraySectionProps {
+ title: string;
+ emptyText: string;
+ items: T[];
+ renderRow: (item: T) => ReactNode;
+ formTitle: (editing: boolean) => string;
+ renderForm: (item: T | undefined) => ReactNode;
+ parseForm: (fd: FormData) => T;
+ onAdd: (item: T) => void;
+ onUpdate: (index: number, item: T) => void;
+ onRemove: (index: number) => void;
+ disabled?: boolean;
+}
+
+function ArraySection({
+ title,
+ emptyText,
+ items,
+ renderRow,
+ formTitle,
+ renderForm,
+ parseForm,
+ onAdd,
+ onUpdate,
+ onRemove,
+ disabled,
+}: ArraySectionProps) {
+ const [dialog, setDialog] = useState({ mode: "closed" });
+ const close = () => setDialog({ mode: "closed" });
+ const editing = dialog.mode === "edit" ? items[dialog.index] : undefined;
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const item = parseForm(new FormData(e.currentTarget));
+ if (dialog.mode === "add") onAdd(item);
+ else if (dialog.mode === "edit") onUpdate(dialog.index, item);
+ close();
+ }
+
+ function handleDelete(e: React.FormEvent) {
+ e.preventDefault();
+ if (dialog.mode === "delete") onRemove(dialog.index);
+ close();
+ }
+
+ return (
+
+
+
{title}
+
+
+
+ {items.length === 0 ? (
+
{emptyText}
+ ) : (
+
+ {items.map((item, i) => (
+
+ {renderRow(item)}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+}
+
+type RecordDialogState =
+ | { mode: "closed" }
+ | { mode: "add" }
+ | { mode: "edit"; key: string }
+ | { mode: "delete"; key: string };
+
+interface RecordSectionProps {
+ title: string;
+ emptyText: string;
+ entries: [string, V][];
+ renderRow: (key: string, value: V) => ReactNode;
+ formTitle: (editing: boolean) => string;
+ renderForm: (key: string | undefined, value: V | undefined) => ReactNode;
+ parseForm: (fd: FormData) => { key: string; value: V };
+ onSet: (key: string, value: V) => void;
+ onRename: (oldKey: string, newKey: string, value: V) => void;
+ onRemove: (key: string) => void;
+ disabled?: boolean;
+}
+
+function RecordSection({
+ title,
+ emptyText,
+ entries,
+ renderRow,
+ formTitle,
+ renderForm,
+ parseForm,
+ onSet,
+ onRename,
+ onRemove,
+ disabled,
+}: RecordSectionProps) {
+ const [dialog, setDialog] = useState({ mode: "closed" });
+ const close = () => setDialog({ mode: "closed" });
+ const editKey = dialog.mode === "edit" ? dialog.key : undefined;
+ const editValue = editKey ? entries.find(([k]) => k === editKey)?.[1] : undefined;
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const { key, value } = parseForm(new FormData(e.currentTarget));
+ if (dialog.mode === "edit" && editKey && editKey !== key) {
+ onRename(editKey, key, value);
+ } else {
+ onSet(key, value);
+ }
+ close();
+ }
+
+ function handleDelete(e: React.FormEvent) {
+ e.preventDefault();
+ if (dialog.mode === "delete") onRemove(dialog.key);
+ close();
+ }
+
+ return (
+
+
+
{title}
+
+
+
+ {entries.length === 0 ? (
+
{emptyText}
+ ) : (
+
+ {entries.map(([key, value]) => (
+
+ {renderRow(key, value)}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+}
+
+interface VisualEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ disabled?: boolean;
+}
+
+export default function VisualEditor({ value, onChange, disabled }: VisualEditorProps) {
+ const policy = parsePolicy(value);
+ const emit = (p: AclPolicy) => onChange(stringifyPolicy(p));
+
+ return (
+
+ {/* ACL Rules */}
+
+ title="ACL Rules"
+ emptyText="No ACL rules defined"
+ disabled={disabled}
+ items={policy.acls ?? []}
+ renderRow={(r) => (
+
+
+
+ →
+
+
+ {r.proto &&
{r.proto}}
+
+ )}
+ formTitle={(editing) => (editing ? "Edit Rule" : "Add Rule")}
+ renderForm={(item) => (
+ <>
+
+
+
+ >
+ )}
+ parseForm={(fd) => ({
+ action: "accept" as const,
+ src: split(fd.get("src") as string),
+ dst: split(fd.get("dst") as string),
+ ...((fd.get("proto") as string)?.trim()
+ ? { proto: (fd.get("proto") as string).trim() }
+ : {}),
+ })}
+ onAdd={(rule) => emit(addAclRule(policy, rule))}
+ onUpdate={(i, rule) => emit(updateAclRule(policy, i, rule))}
+ onRemove={(i) => emit(removeAclRule(policy, i))}
+ />
+
+ {/* Groups */}
+
+ title="Groups"
+ emptyText="No groups defined"
+ disabled={disabled}
+ entries={Object.entries(policy.groups ?? {})}
+ renderRow={(key, members) => (
+
+ {key}
+
+
+ )}
+ formTitle={(editing) => (editing ? "Edit Group" : "Add Group")}
+ renderForm={(key, members) => (
+ <>
+
+
+ >
+ )}
+ parseForm={(fd) => ({
+ key: groupKey((fd.get("name") as string).trim()),
+ value: split(fd.get("members") as string),
+ })}
+ onSet={(key, members) => emit(setGroup(policy, key, members))}
+ onRename={(oldKey, newKey, members) =>
+ emit(setGroup(removeGroup(policy, oldKey), newKey, members))
+ }
+ onRemove={(key) => emit(removeGroup(policy, key))}
+ />
+
+ {/* Hosts */}
+
+ title="Hosts"
+ emptyText="No host aliases defined"
+ disabled={disabled}
+ entries={Object.entries(policy.hosts ?? {})}
+ renderRow={(name, addr) => (
+
+ {name}
+ →
+ {addr}
+
+ )}
+ formTitle={(editing) => (editing ? "Edit Host" : "Add Host")}
+ renderForm={(key, addr) => (
+ <>
+
+
+ >
+ )}
+ parseForm={(fd) => ({
+ key: (fd.get("name") as string).trim(),
+ value: (fd.get("address") as string).trim(),
+ })}
+ onSet={(name, addr) => emit(setHost(policy, name, addr))}
+ onRename={(oldKey, newKey, addr) => emit(setHost(removeHost(policy, oldKey), newKey, addr))}
+ onRemove={(name) => emit(removeHost(policy, name))}
+ />
+
+ {/* Tag Owners */}
+
+ title="Tag Owners"
+ emptyText="No tag owners defined"
+ disabled={disabled}
+ entries={Object.entries(policy.tagOwners ?? {})}
+ renderRow={(tag, owners) => (
+
+ {tag}
+
+
+ )}
+ formTitle={(editing) => (editing ? "Edit Tag Owner" : "Add Tag Owner")}
+ renderForm={(key, owners) => (
+ <>
+
+
+ >
+ )}
+ parseForm={(fd) => ({
+ key: tagKey((fd.get("tag") as string).trim()),
+ value: split(fd.get("owners") as string),
+ })}
+ onSet={(tag, owners) => emit(setTagOwner(policy, tag, owners))}
+ onRename={(oldKey, newKey, owners) =>
+ emit(setTagOwner(removeTagOwner(policy, oldKey), newKey, owners))
+ }
+ onRemove={(tag) => emit(removeTagOwner(policy, tag))}
+ />
+
+ {/* SSH Rules */}
+
+ title="SSH Rules"
+ emptyText="No SSH rules defined"
+ disabled={disabled}
+ items={policy.ssh ?? []}
+ renderRow={(r) => (
+
+
+
+
+ →
+
+
+
+ as
+
+ {r.checkPeriod && every {r.checkPeriod}}
+
+
+ )}
+ formTitle={(editing) => (editing ? "Edit SSH Rule" : "Add SSH Rule")}
+ renderForm={(item) => (
+ <>
+
+
+
+
+
+ >
+ )}
+ parseForm={(fd) => ({
+ action:
+ (fd.get("action") as string) === "check" ? ("check" as const) : ("accept" as const),
+ src: split(fd.get("src") as string),
+ dst: split(fd.get("dst") as string),
+ users: split(fd.get("users") as string),
+ ...((fd.get("checkPeriod") as string)?.trim()
+ ? { checkPeriod: (fd.get("checkPeriod") as string).trim() }
+ : {}),
+ })}
+ onAdd={(rule) => emit(addSshRule(policy, rule))}
+ onUpdate={(i, rule) => emit(updateSshRule(policy, i, rule))}
+ onRemove={(i) => emit(removeSshRule(policy, i))}
+ />
+
+ );
+}
diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx
index 51206f9f..650ff1e6 100644
--- a/app/routes/acls/overview.tsx
+++ b/app/routes/acls/overview.tsx
@@ -1,4 +1,4 @@
-import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
+import { AlertCircle, Construction, Eye, FlaskConical, LayoutGrid, Pencil } from "lucide-react";
import { Suspense, lazy, useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
@@ -23,6 +23,7 @@ const LazyEditor = lazy(() =>
const LazyDiffer = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Differ })),
);
+const LazyVisualEditor = lazy(() => import("./components/visual-editor"));
export const loader = aclLoader;
export const action = aclAction;
@@ -100,6 +101,12 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
Preview changes
+
+
+
+ Visual editor
+
+
@@ -117,6 +124,11 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
+
+ }>
+
+
+
diff --git a/app/utils/acl-editor.ts b/app/utils/acl-editor.ts
new file mode 100644
index 00000000..c8b68b3a
--- /dev/null
+++ b/app/utils/acl-editor.ts
@@ -0,0 +1,124 @@
+import { assign, parse, stringify } from "comment-json";
+
+export interface AclRule {
+ action: "accept";
+ src: string[];
+ dst: string[];
+ proto?: string;
+}
+
+export interface SshRule {
+ action: "accept" | "check";
+ src: string[];
+ dst: string[];
+ users: string[];
+ checkPeriod?: string;
+}
+
+export interface AclPolicy {
+ acls?: AclRule[];
+ groups?: Record
;
+ hosts?: Record;
+ tagOwners?: Record;
+ ssh?: SshRule[];
+ autoApprovers?: { routes?: Record; exitNode?: string[] };
+ tests?: unknown[];
+}
+
+export function parsePolicy(raw: string): AclPolicy {
+ if (!raw.trim()) return {};
+ return parse(raw) as AclPolicy;
+}
+
+export function stringifyPolicy(policy: AclPolicy): string {
+ return stringify(policy, null, 2);
+}
+
+function patch(policy: AclPolicy, changes: Partial): AclPolicy {
+ return assign(assign({} as AclPolicy, policy), changes) as AclPolicy;
+}
+
+type ArrayField = "acls" | "ssh";
+
+function appendTo(
+ policy: AclPolicy,
+ key: K,
+ item: NonNullable[number],
+): AclPolicy {
+ return patch(policy, {
+ [key]: [...((policy[key] as unknown[]) ?? []), item],
+ } as Partial);
+}
+
+function removeAt(policy: AclPolicy, key: K, index: number): AclPolicy {
+ const arr = [...((policy[key] as unknown[]) ?? [])];
+ if (index < 0 || index >= arr.length) return policy;
+ arr.splice(index, 1);
+ return patch(policy, { [key]: arr } as Partial);
+}
+
+function replaceAt(
+ policy: AclPolicy,
+ key: K,
+ index: number,
+ item: NonNullable[number],
+): AclPolicy {
+ const arr = [...((policy[key] as unknown[]) ?? [])];
+ if (index < 0 || index >= arr.length) return policy;
+ arr[index] = item;
+ return patch(policy, { [key]: arr } as Partial);
+}
+
+type RecordField = "groups" | "hosts" | "tagOwners";
+
+function setEntry(
+ policy: AclPolicy,
+ key: K,
+ entryKey: string,
+ value: NonNullable[string],
+): AclPolicy {
+ return patch(policy, {
+ [key]: { ...(policy[key] as Record), [entryKey]: value },
+ } as Partial);
+}
+
+function removeEntry(
+ policy: AclPolicy,
+ key: K,
+ entryKey: string,
+): AclPolicy {
+ const map = { ...(policy[key] as Record) };
+ delete map[entryKey];
+ return patch(policy, { [key]: map } as Partial);
+}
+
+export function groupKey(name: string) {
+ return name.startsWith("group:") ? name : `group:${name}`;
+}
+
+export function tagKey(name: string) {
+ return name.startsWith("tag:") ? name : `tag:${name}`;
+}
+
+export const addAclRule = (p: AclPolicy, rule: AclRule) => appendTo(p, "acls", rule);
+export const removeAclRule = (p: AclPolicy, i: number) => removeAt(p, "acls", i);
+export const updateAclRule = (p: AclPolicy, i: number, rule: AclRule) =>
+ replaceAt(p, "acls", i, rule);
+
+export const addSshRule = (p: AclPolicy, rule: SshRule) => appendTo(p, "ssh", rule);
+export const removeSshRule = (p: AclPolicy, i: number) => removeAt(p, "ssh", i);
+export const updateSshRule = (p: AclPolicy, i: number, rule: SshRule) =>
+ replaceAt(p, "ssh", i, rule);
+
+export const setGroup = (p: AclPolicy, name: string, members: string[]) =>
+ setEntry(p, "groups", groupKey(name), members);
+export const removeGroup = (p: AclPolicy, name: string) => removeEntry(p, "groups", groupKey(name));
+
+export const setHost = (p: AclPolicy, name: string, addr: string) =>
+ setEntry(p, "hosts", name, addr);
+export const removeHost = (p: AclPolicy, name: string) => removeEntry(p, "hosts", name);
+
+export const setTagOwner = (p: AclPolicy, tag: string, owners: string[]) =>
+ setEntry(p, "tagOwners", tagKey(tag), owners);
+export const removeTagOwner = (p: AclPolicy, tag: string) =>
+ removeEntry(p, "tagOwners", tagKey(tag));
diff --git a/nix/package.nix b/nix/package.nix
index 9a4774b5..67b719c9 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -33,7 +33,7 @@ in
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
pnpm = pnpm_10;
- hash = "sha256-NGIeboj/2kXuWsmTVl1fv4LgU1VYRdO+qSnNLVuneC8=";
+ hash = "sha256-OTd6+KxPc0NZyPiof6DNAH+bZouSkKHN5YTPjL1ko1E=";
};
buildPhase = ''
diff --git a/package.json b/package.json
index 058317bd..a778b382 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@uiw/react-codemirror": "4.25.9",
"arktype": "^2.2.0",
"clsx": "^2.1.1",
+ "comment-json": "^5.0.0",
"drizzle-orm": "1.0.0-beta.21",
"isbot": "5.1.37",
"jose": "6.2.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2a6a48a0..028783fe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,6 +59,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ comment-json:
+ specifier: ^5.0.0
+ version: 5.0.0
drizzle-orm:
specifier: 1.0.0-beta.21
version: 1.0.0-beta.21(@libsql/client@0.17.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(arktype@2.2.0)(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(valibot@1.3.1(typescript@6.0.2))
@@ -2163,6 +2166,9 @@ packages:
arktype@2.2.0:
resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==}
+ array-timsort@1.0.3:
+ resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==}
+
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
@@ -2394,6 +2400,10 @@ packages:
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
+ comment-json@5.0.0:
+ resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==}
+ engines: {node: '>= 6'}
+
compress-commons@6.0.2:
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>= 14'}
@@ -2706,6 +2716,11 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ esprima@4.0.1:
+ resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+ engines: {node: '>=4'}
+ hasBin: true
+
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -3873,6 +3888,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
valibot@1.3.1:
@@ -5939,6 +5955,8 @@ snapshots:
'@ark/util': 0.56.0
arkregex: 0.0.5
+ array-timsort@1.0.3: {}
+
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
@@ -6162,6 +6180,11 @@ snapshots:
commander@2.20.3:
optional: true
+ comment-json@5.0.0:
+ dependencies:
+ array-timsort: 1.0.3
+ esprima: 4.0.1
+
compress-commons@6.0.2:
dependencies:
crc-32: 1.2.2
@@ -6434,6 +6457,8 @@ snapshots:
escalade@3.2.0: {}
+ esprima@4.0.1: {}
+
estree-walker@2.0.2: {}
estree-walker@3.0.3:
diff --git a/tests/integration/api/nodes.test.ts b/tests/integration/api/nodes.test.ts
index 7565dd90..5bddf662 100644
--- a/tests/integration/api/nodes.test.ts
+++ b/tests/integration/api/nodes.test.ts
@@ -53,7 +53,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
await client.setNodeUser(workingNodeId, user.id);
const reassignedNode = await client.getNode(workingNodeId);
expect(reassignedNode).toBeDefined();
- expect(reassignedNode.user.name).toBe(user.name);
+ expect(reassignedNode.user?.name).toBe(user.name);
});
test("nodes can be expired", async () => {
diff --git a/tests/unit/utils/acl-editor.test.ts b/tests/unit/utils/acl-editor.test.ts
new file mode 100644
index 00000000..7d70e266
--- /dev/null
+++ b/tests/unit/utils/acl-editor.test.ts
@@ -0,0 +1,387 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ type AclPolicy,
+ type AclRule,
+ type SshRule,
+ addAclRule,
+ addSshRule,
+ groupKey,
+ parsePolicy,
+ removeAclRule,
+ removeGroup,
+ removeHost,
+ removeSshRule,
+ removeTagOwner,
+ setGroup,
+ setHost,
+ setTagOwner,
+ stringifyPolicy,
+ tagKey,
+ updateAclRule,
+ updateSshRule,
+} from "~/utils/acl-editor";
+
+const FULL = `{
+ "acls": [
+ {"action": "accept", "src": ["group:admin"], "dst": ["*:*"]},
+ {"action": "accept", "src": ["group:dev"], "dst": ["tag:server:22"]}
+ ],
+ "groups": {
+ "group:admin": ["user1", "user2"],
+ "group:dev": ["user3"]
+ },
+ "hosts": {
+ "server1": "100.64.0.1",
+ "server2": "100.64.0.2"
+ },
+ "tagOwners": {
+ "tag:server": ["group:admin"],
+ "tag:ci": ["user3"]
+ },
+ "ssh": [
+ {"action": "accept", "src": ["group:admin"], "dst": ["tag:server"], "users": ["root"]}
+ ]
+}`;
+
+const WITH_COMMENTS = `{
+ // Main access rules
+ "acls": [
+ {"action": "accept", "src": ["group:admin"], "dst": ["*:*"]},
+ ],
+ // Team groups
+ "groups": {
+ "group:admin": ["alice", "bob"],
+ },
+}`;
+
+const WITH_EXTRA_FIELDS = `{
+ "acls": [
+ {"action": "accept", "src": ["*"], "dst": ["*:*"]}
+ ],
+ "autoApprovers": {
+ "routes": {"10.0.0.0/8": ["group:admin"]},
+ "exitNode": ["group:admin"]
+ },
+ "tests": [
+ {"src": "user1", "accept": ["100.64.0.1:80"]}
+ ]
+}`;
+
+const rule = (o?: Partial): AclRule => ({
+ action: "accept",
+ src: ["*"],
+ dst: ["*:*"],
+ ...o,
+});
+
+const ssh = (o?: Partial): SshRule => ({
+ action: "accept",
+ src: ["group:admin"],
+ dst: ["tag:server"],
+ users: ["root"],
+ ...o,
+});
+
+describe("parsePolicy", () => {
+ test("empty input returns empty object", () => {
+ expect(parsePolicy("")).toEqual({});
+ expect(parsePolicy(" ")).toEqual({});
+ });
+
+ test("parses all policy sections", () => {
+ const p = parsePolicy(FULL);
+ expect(p.acls).toHaveLength(2);
+ expect(Object.keys(p.groups ?? {})).toEqual(["group:admin", "group:dev"]);
+ expect(Object.keys(p.hosts ?? {})).toEqual(["server1", "server2"]);
+ expect(Object.keys(p.tagOwners ?? {})).toEqual(["tag:server", "tag:ci"]);
+ expect(p.ssh).toHaveLength(1);
+ });
+
+ test("handles HuJSON comments and trailing commas", () => {
+ const p = parsePolicy(WITH_COMMENTS);
+ expect(p.acls).toHaveLength(1);
+ expect(p.groups?.["group:admin"]).toEqual(["alice", "bob"]);
+ });
+
+ test("parses policy with empty arrays", () => {
+ const p = parsePolicy(`{"acls": [], "ssh": []}`);
+ expect(p.acls).toEqual([]);
+ expect(p.ssh).toEqual([]);
+ });
+
+ test("throws on invalid JSON", () => {
+ expect(() => parsePolicy("{invalid}")).toThrow();
+ });
+});
+
+describe("stringifyPolicy", () => {
+ test("round-trips preserve data", () => {
+ const final = parsePolicy(stringifyPolicy(parsePolicy(FULL)));
+ expect(final.acls).toHaveLength(2);
+ expect(final.groups?.["group:admin"]).toEqual(["user1", "user2"]);
+ expect(final.hosts?.server1).toBe("100.64.0.1");
+ });
+
+ test("preserves section-level HuJSON comments", () => {
+ const output = stringifyPolicy(parsePolicy(WITH_COMMENTS));
+ expect(output).toContain("// Main access rules");
+ expect(output).toContain("// Team groups");
+ });
+});
+
+describe("groupKey / tagKey", () => {
+ test("adds prefix when missing", () => {
+ expect(groupKey("admin")).toBe("group:admin");
+ expect(tagKey("server")).toBe("tag:server");
+ });
+
+ test("is idempotent when prefix already present", () => {
+ expect(groupKey("group:admin")).toBe("group:admin");
+ expect(tagKey("tag:server")).toBe("tag:server");
+ });
+});
+
+describe("array operations", () => {
+ test("appends to empty and existing arrays", () => {
+ expect(addAclRule({}, rule()).acls).toHaveLength(1);
+
+ const added = addAclRule(parsePolicy(FULL), rule({ src: ["group:ops"] }));
+ expect(added.acls).toHaveLength(3);
+ expect(added.acls?.[2].src).toEqual(["group:ops"]);
+ });
+
+ test("appends to an already-empty array field", () => {
+ const p = parsePolicy(`{"acls": []}`);
+ const added = addAclRule(p, rule());
+ expect(added.acls).toHaveLength(1);
+ });
+
+ test("removes by index", () => {
+ const removed = removeAclRule(parsePolicy(FULL), 0);
+ expect(removed.acls).toHaveLength(1);
+ expect(removed.acls?.[0].src).toEqual(["group:dev"]);
+ });
+
+ test("removing last element leaves empty array", () => {
+ const single = parsePolicy(`{"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]}`);
+ expect(removeAclRule(single, 0).acls).toEqual([]);
+ });
+
+ test("replaces at index without affecting siblings", () => {
+ const updated = updateAclRule(parsePolicy(FULL), 1, rule({ src: ["group:ops"] }));
+ expect(updated.acls?.[1].src).toEqual(["group:ops"]);
+ expect(updated.acls?.[0].src).toEqual(["group:admin"]);
+ });
+
+ test("out-of-bounds and undefined arrays return same reference", () => {
+ const p = parsePolicy(FULL);
+ expect(removeAclRule(p, -1)).toBe(p);
+ expect(removeAclRule(p, 999)).toBe(p);
+ expect(updateAclRule(p, -1, rule())).toBe(p);
+ expect(updateAclRule(p, 999, rule())).toBe(p);
+
+ const empty: AclPolicy = {};
+ expect(removeAclRule(empty, 0)).toBe(empty);
+ expect(updateAclRule(empty, 0, rule())).toBe(empty);
+ });
+
+ test("ssh rules use the same mechanics", () => {
+ expect(addSshRule({}, ssh()).ssh).toHaveLength(1);
+ expect(removeSshRule(parsePolicy(FULL), 0).ssh).toHaveLength(0);
+
+ const updated = updateSshRule(
+ parsePolicy(FULL),
+ 0,
+ ssh({ action: "check", checkPeriod: "12h" }),
+ );
+ expect(updated.ssh?.[0].action).toBe("check");
+ expect(updated.ssh?.[0].checkPeriod).toBe("12h");
+ });
+});
+
+describe("record operations", () => {
+ test("sets new entries with auto-prefix", () => {
+ expect(setGroup({}, "ops", ["a"]).groups?.["group:ops"]).toEqual(["a"]);
+ expect(setHost({}, "web", "10.0.0.1").hosts?.web).toBe("10.0.0.1");
+ expect(setTagOwner({}, "web", ["group:ops"]).tagOwners?.["tag:web"]).toEqual(["group:ops"]);
+ });
+
+ test("overwrites existing entries without affecting siblings", () => {
+ const updated = setGroup(parsePolicy(FULL), "group:admin", ["newuser"]);
+ expect(updated.groups?.["group:admin"]).toEqual(["newuser"]);
+ expect(updated.groups?.["group:dev"]).toEqual(["user3"]);
+ });
+
+ test("allows setting empty member lists", () => {
+ const p = setGroup({}, "empty", []);
+ expect(p.groups?.["group:empty"]).toEqual([]);
+ });
+
+ test("removes entries with auto-prefix", () => {
+ const p = parsePolicy(FULL);
+ expect(removeGroup(p, "dev").groups?.["group:dev"]).toBeUndefined();
+ expect(removeHost(p, "server1").hosts?.server1).toBeUndefined();
+ expect(removeTagOwner(p, "ci").tagOwners?.["tag:ci"]).toBeUndefined();
+ });
+
+ test("removing non-existent keys leaves record unchanged", () => {
+ const p = parsePolicy(FULL);
+ expect(Object.keys(removeGroup(p, "nonexistent").groups ?? {})).toEqual(
+ Object.keys(p.groups ?? {}),
+ );
+ });
+
+ test("operations on undefined fields produce empty records", () => {
+ const empty: AclPolicy = {};
+ expect(removeGroup(empty, "ops").groups).toEqual({});
+ expect(removeHost(empty, "web").hosts).toEqual({});
+ expect(removeTagOwner(empty, "web").tagOwners).toEqual({});
+ });
+
+ test("rename via remove+set preserves siblings", () => {
+ const p = parsePolicy(FULL);
+ const renamed = setGroup(removeGroup(p, "group:dev"), "group:engineering", ["user3", "user4"]);
+ expect(renamed.groups?.["group:dev"]).toBeUndefined();
+ expect(renamed.groups?.["group:engineering"]).toEqual(["user3", "user4"]);
+ expect(renamed.groups?.["group:admin"]).toEqual(["user1", "user2"]);
+ });
+});
+
+describe("immutability", () => {
+ test("mutations never alter the source policy", () => {
+ const p = parsePolicy(FULL);
+ const snapshot = stringifyPolicy(p);
+
+ addAclRule(p, rule());
+ removeAclRule(p, 0);
+ updateAclRule(p, 0, rule({ src: ["changed"] }));
+ addSshRule(p, ssh());
+ removeSshRule(p, 0);
+ updateSshRule(p, 0, ssh({ users: ["ubuntu"] }));
+ setGroup(p, "new", ["x"]);
+ removeGroup(p, "group:admin");
+ setHost(p, "server1", "10.0.0.99");
+ removeHost(p, "server1");
+ setTagOwner(p, "tag:server", ["changed"]);
+ removeTagOwner(p, "tag:ci");
+
+ expect(stringifyPolicy(p)).toBe(snapshot);
+ });
+});
+
+describe("field isolation", () => {
+ test("unrecognized fields survive mutations", () => {
+ let p = parsePolicy(WITH_EXTRA_FIELDS);
+ p = addAclRule(p, rule({ src: ["group:ops"] }));
+ p = setGroup(p, "ops", ["admin1"]);
+
+ const final = parsePolicy(stringifyPolicy(p));
+ expect(final.autoApprovers?.routes?.["10.0.0.0/8"]).toEqual(["group:admin"]);
+ expect(final.autoApprovers?.exitNode).toEqual(["group:admin"]);
+ expect(final.tests).toHaveLength(1);
+ });
+
+ test("sibling sections survive targeted removal", () => {
+ const r = removeAclRule(parsePolicy(FULL), 0);
+ expect(Object.keys(r.groups ?? {})).toEqual(["group:admin", "group:dev"]);
+ expect(Object.keys(r.hosts ?? {})).toEqual(["server1", "server2"]);
+ expect(r.ssh).toHaveLength(1);
+ });
+
+ test("section-level comments survive mutations on other fields", () => {
+ const output = stringifyPolicy(setHost(parsePolicy(WITH_COMMENTS), "web", "10.0.0.5"));
+ expect(output).toContain("// Main access rules");
+ expect(output).toContain("// Team groups");
+ });
+});
+
+describe("end-to-end", () => {
+ test("build policy from scratch and round-trip", () => {
+ let p: AclPolicy = {};
+ p = setGroup(p, "admin", ["alice", "bob"]);
+ p = setTagOwner(p, "server", ["group:admin"]);
+ p = setHost(p, "gateway", "100.64.0.1");
+ p = addAclRule(p, rule({ src: ["group:admin"], dst: ["*:*"] }));
+ p = addSshRule(p, ssh());
+
+ const final = parsePolicy(stringifyPolicy(p));
+ expect(final.groups?.["group:admin"]).toEqual(["alice", "bob"]);
+ expect(final.tagOwners?.["tag:server"]).toEqual(["group:admin"]);
+ expect(final.hosts?.gateway).toBe("100.64.0.1");
+ expect(final.acls).toHaveLength(1);
+ expect(final.ssh).toHaveLength(1);
+ });
+
+ test("chained mutations across sections", () => {
+ let p = parsePolicy(FULL);
+ p = addAclRule(p, rule({ src: ["group:temp"] }));
+ p = removeAclRule(p, 2);
+ p = updateAclRule(p, 0, rule({ src: ["group:ops"] }));
+ p = setGroup(p, "temp", ["user1"]);
+ p = removeGroup(p, "temp");
+
+ const final = parsePolicy(stringifyPolicy(p));
+ expect(final.acls).toHaveLength(2);
+ expect(final.acls?.[0].src).toEqual(["group:ops"]);
+ expect(final.groups?.["group:temp"]).toBeUndefined();
+ expect(final.groups?.["group:admin"]).toEqual(["user1", "user2"]);
+ });
+
+ test("optional fields survive round-trips", () => {
+ let p: AclPolicy = {};
+ p = addAclRule(p, rule({ proto: "udp", dst: ["*:53"] }));
+ p = addSshRule(p, ssh({ action: "check", checkPeriod: "24h" }));
+
+ const final = parsePolicy(stringifyPolicy(p));
+ expect(final.acls?.[0].proto).toBe("udp");
+ expect(final.ssh?.[0].checkPeriod).toBe("24h");
+ });
+
+ test("realistic policy with multiple mutation passes", () => {
+ let p: AclPolicy = {};
+
+ p = setGroup(p, "engineering", ["alice", "bob", "charlie"]);
+ p = setGroup(p, "ops", ["dave", "eve"]);
+ p = setGroup(p, "contractors", ["frank"]);
+ p = setHost(p, "prod-db", "100.64.1.10");
+ p = setHost(p, "staging-db", "100.64.2.10");
+ p = setHost(p, "monitoring", "100.64.3.1");
+ p = setTagOwner(p, "production", ["group:ops"]);
+ p = setTagOwner(p, "staging", ["group:engineering", "group:ops"]);
+ p = addAclRule(p, rule({ src: ["group:ops"], dst: ["*:*"] }));
+ p = addAclRule(p, rule({ src: ["group:engineering"], dst: ["tag:staging:*"] }));
+ p = addAclRule(p, rule({ src: ["group:contractors"], dst: ["tag:staging:443"] }));
+ p = addSshRule(
+ p,
+ ssh({ src: ["group:ops"], dst: ["tag:production"], users: ["root", "ubuntu"] }),
+ );
+ p = addSshRule(
+ p,
+ ssh({
+ action: "check",
+ src: ["group:engineering"],
+ dst: ["tag:staging"],
+ users: ["ubuntu"],
+ checkPeriod: "12h",
+ }),
+ );
+
+ // Simulate ongoing edits: rename group, remove contractor access, add host
+ p = setGroup(removeGroup(p, "contractors"), "external", ["frank", "grace"]);
+ p = updateAclRule(p, 2, rule({ src: ["group:external"], dst: ["tag:staging:443"] }));
+ p = setHost(p, "cache", "100.64.3.5");
+ p = removeHost(p, "monitoring");
+
+ const final = parsePolicy(stringifyPolicy(p));
+ expect(Object.keys(final.groups ?? {})).toHaveLength(3);
+ expect(final.groups?.["group:external"]).toEqual(["frank", "grace"]);
+ expect(final.groups?.["group:contractors"]).toBeUndefined();
+ expect(final.hosts?.cache).toBe("100.64.3.5");
+ expect(final.hosts?.monitoring).toBeUndefined();
+ expect(final.acls).toHaveLength(3);
+ expect(final.acls?.[2].src).toEqual(["group:external"]);
+ expect(final.ssh).toHaveLength(2);
+ expect(final.ssh?.[1].checkPeriod).toBe("12h");
+ });
+});