diff --git a/app/utils/acl-editor.ts b/app/utils/acl-editor.ts new file mode 100644 index 00000000..909d1f29 --- /dev/null +++ b/app/utils/acl-editor.ts @@ -0,0 +1,133 @@ +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); +} + +// comment-json stores comments as Symbols which get lost in spread. +function patch(policy: AclPolicy, changes: Partial): AclPolicy { + return assign(assign({} as AclPolicy, policy), changes) as AclPolicy; +} + +// Generic array operations on a policy field +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); +} + +// Generic record operations on a policy field +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); +} + +// Prefix helpers +function groupKey(name: string) { + return name.startsWith("group:") ? name : `group:${name}`; +} + +function tagKey(name: string) { + return name.startsWith("tag:") ? name : `tag:${name}`; +} + +// ACL rules +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); + +// SSH rules +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); + +// Groups +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)); + +// Hosts +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); + +// Tag owners +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/unit/utils/acl-editor.test.ts b/tests/unit/utils/acl-editor.test.ts new file mode 100644 index 00000000..03d8c561 --- /dev/null +++ b/tests/unit/utils/acl-editor.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from "vitest"; + +import { + type AclPolicy, + type AclRule, + type SshRule, + addAclRule, + addSshRule, + parsePolicy, + removeAclRule, + removeGroup, + removeHost, + removeSshRule, + removeTagOwner, + setGroup, + setHost, + setTagOwner, + stringifyPolicy, + 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": [ + // Allow admins everywhere + {"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("parsing", () => { + test("empty/whitespace 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 + trailing commas)", () => { + const p = parsePolicy(WITH_COMMENTS); + expect(p.acls).toHaveLength(1); + expect(p.groups?.["group:admin"]).toEqual(["alice", "bob"]); + }); + + test("rejects invalid input", () => { + expect(() => parsePolicy("{invalid}")).toThrow(); + }); + + test("stringify round-trips preserve data and section-level comments", () => { + const output = stringifyPolicy(parsePolicy(WITH_COMMENTS)); + expect(output).toContain("// Main access rules"); + expect(output).toContain("// Team groups"); + const reparsed = parsePolicy(output); + expect(reparsed.acls).toHaveLength(1); + expect(reparsed.groups?.["group:admin"]).toEqual(["alice", "bob"]); + }); +}); + +// Tests array generics (appendTo/removeAt/replaceAt) via acl + ssh wrappers +describe("array operations", () => { + test("append to empty and existing arrays", () => { + const fromEmpty = addAclRule({}, rule()); + expect(fromEmpty.acls).toHaveLength(1); + + const fromExisting = addAclRule(parsePolicy(FULL), rule({ src: ["group:ops"] })); + expect(fromExisting.acls).toHaveLength(3); + expect(fromExisting.acls?.[2].src).toEqual(["group:ops"]); + }); + + test("remove by index, out-of-bounds no-op, and last-element removal", () => { + const p = parsePolicy(FULL); + + const removed = removeAclRule(p, 0); + expect(removed.acls).toHaveLength(1); + expect(removed.acls?.[0].src).toEqual(["group:dev"]); + + // Out of bounds and undefined arrays return same ref + expect(removeAclRule(p, 99)).toBe(p); + expect(removeAclRule(p, -1)).toBe(p); + expect(removeAclRule({}, 0)).toEqual({}); + + // Removing last element leaves empty array + const single = parsePolicy(`{"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]}`); + expect(removeAclRule(single, 0).acls).toEqual([]); + }); + + test("replace at index", () => { + const p = parsePolicy(FULL); + const updated = updateAclRule(p, 1, rule({ src: ["group:ops"], dst: ["*:443"] })); + expect(updated.acls?.[1].src).toEqual(["group:ops"]); + expect(updated.acls?.[0].src).toEqual(["group:admin"]); // untouched + expect(updateAclRule(p, 99, rule())).toBe(p); // out of bounds + }); + + test("ssh rules use same generics", () => { + const added = addSshRule({}, ssh()); + expect(added.ssh).toHaveLength(1); + + const p = parsePolicy(FULL); + expect(removeSshRule(p, 0).ssh).toHaveLength(0); + + const updated = updateSshRule(p, 0, ssh({ action: "check", checkPeriod: "12h" })); + expect(updated.ssh?.[0].action).toBe("check"); + expect(updated.ssh?.[0].checkPeriod).toBe("12h"); + }); +}); + +// Tests record generics (setEntry/removeEntry) via groups/hosts/tags wrappers +describe("record operations", () => { + test("set new, overwrite existing, and auto-prefix", () => { + // Groups: new + overwrite + prefix + expect(setGroup({}, "ops", ["a"]).groups?.["group:ops"]).toEqual(["a"]); + const p = parsePolicy(FULL); + const updated = setGroup(p, "group:admin", ["newuser"]); + expect(updated.groups?.["group:admin"]).toEqual(["newuser"]); + expect(updated.groups?.["group:dev"]).toEqual(["user3"]); // sibling untouched + + // Hosts + expect(setHost({}, "web", "10.0.0.1").hosts?.web).toBe("10.0.0.1"); + + // Tags with auto-prefix + expect(setTagOwner({}, "web", ["group:ops"]).tagOwners?.["tag:web"]).toEqual(["group:ops"]); + }); + + test("remove existing, no-op for missing, and auto-prefix", () => { + const p = parsePolicy(FULL); + + const r = removeGroup(p, "dev"); // auto-prefixed + expect(r.groups?.["group:dev"]).toBeUndefined(); + expect(r.groups?.["group:admin"]).toBeDefined(); + + expect(removeHost(p, "server1").hosts?.server1).toBeUndefined(); + expect(removeTagOwner(p, "ci").tagOwners?.["tag:ci"]).toBeUndefined(); + + // No-op for missing keys + const noOp = removeGroup(p, "nonexistent"); + expect(Object.keys(noOp.groups ?? {})).toEqual(Object.keys(p.groups ?? {})); + }); +}); + +describe("immutability", () => { + test("no mutation function alters 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("comment preservation", () => { + test("section-level comments survive mutations on any field", () => { + const p = parsePolicy(WITH_COMMENTS); + const output = stringifyPolicy(setHost(p, "web", "10.0.0.5")); + expect(output).toContain("// Main access rules"); + expect(output).toContain("// Team groups"); + }); + + test("inline array element comments are lost on array mutation", () => { + // comment-json stores inline comments as Symbols on array elements. + // Spreading into a new array drops them — known tradeoff of immutability. + const p = parsePolicy(WITH_COMMENTS); + const output = stringifyPolicy(addAclRule(p, rule())); + expect(output).not.toContain("// Allow admins everywhere"); + expect(output).toContain("// Main access rules"); // section-level preserved + }); +}); + +describe("field isolation", () => { + test("autoApprovers and tests survive unrelated mutations", () => { + let p = parsePolicy(WITH_EXTRA_FIELDS); + p = addAclRule(p, rule({ src: ["group:ops"] })); + p = setGroup(p, "ops", ["admin1"]); + p = setHost(p, "web", "10.0.0.5"); + + 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); + expect(final.acls).toHaveLength(2); + }); + + test("groups/hosts/tags/ssh survive acl 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); + }); +}); + +describe("error handling & edge cases", () => { + test("parsePolicy rejects invalid JSON but doesn't crash on non-object values", () => { + expect(() => parsePolicy("{invalid}")).toThrow(); + // comment-json wraps primitives in boxed objects, so just verify no crash + expect(() => parsePolicy('"just a string"')).not.toThrow(); + expect(() => parsePolicy("null")).not.toThrow(); + expect(() => parsePolicy("42")).not.toThrow(); + }); + + test("array ops on undefined fields default to empty", () => { + const empty: AclPolicy = {}; + // append creates the array + expect(addAclRule(empty, rule()).acls).toHaveLength(1); + expect(addSshRule(empty, ssh()).ssh).toHaveLength(1); + // remove/update on undefined returns same ref (no-op) + expect(removeAclRule(empty, 0)).toBe(empty); + expect(updateAclRule(empty, 0, rule())).toBe(empty); + expect(removeSshRule(empty, 0)).toBe(empty); + expect(updateSshRule(empty, 0, ssh())).toBe(empty); + }); + + test("record ops on undefined fields default to empty", () => { + const empty: AclPolicy = {}; + expect(setGroup(empty, "ops", ["a"]).groups?.["group:ops"]).toEqual(["a"]); + expect(setHost(empty, "web", "10.0.0.1").hosts?.web).toBe("10.0.0.1"); + expect(setTagOwner(empty, "web", ["a"]).tagOwners?.["tag:web"]).toEqual(["a"]); + // remove from undefined field doesn't crash + const r1 = removeGroup(empty, "ops"); + expect(r1.groups).toEqual({}); + const r2 = removeHost(empty, "web"); + expect(r2.hosts).toEqual({}); + const r3 = removeTagOwner(empty, "web"); + expect(r3.tagOwners).toEqual({}); + }); + + test("out-of-bounds array operations 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); + expect(removeSshRule(p, -1)).toBe(p); + expect(updateSshRule(p, 999, ssh())).toBe(p); + }); +}); + +describe("end-to-end workflows", () => { + 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 add/remove/update across sections", () => { + let p = parsePolicy(FULL); + p = addAclRule(p, rule({ src: ["group:temp"] })); + p = removeAclRule(p, 2); // remove the one we just added + 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 (proto, checkPeriod) 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"); + }); +});