feat(ui): structured editor for ACL rules, tags and groups (#608)

This commit is contained in:
albedev
2026-08-28 21:58:06 +02:00
committed by GitHub
parent 30c842ed8d
commit 72ea6aa0b3
28 changed files with 2414 additions and 37 deletions
+337
View File
@@ -0,0 +1,337 @@
import { describe, expect, test } from "vitest";
import {
asUserReference,
groupsForUser,
hasPortSpec,
isValidGroupName,
isValidHostName,
isValidTagName,
parsePolicy,
policyDestinations,
policySources,
serializePolicy,
setUserGroups,
withDefaultPort,
} from "~/utils/acl-policy";
const POLICY = `{
// Teams that can be referenced from rules
"groups": {
"group:eng": ["alice@", "bob@"],
"group:ops": ["ops@"]
},
"tagOwners": {
"tag:server": ["group:ops"]
},
"hosts": {
"office": "100.64.0.0/24"
},
"acls": [
{ "action": "accept", "src": ["group:eng"], "dst": ["tag:server:22"] }
],
"ssh": [
{ "action": "check", "src": ["group:ops"], "dst": ["tag:server"], "users": ["root"], "checkPeriod": "12h" }
],
"autoApprovers": {
"routes": { "10.0.0.0/8": ["group:ops"] }
}
}`;
function parseOrThrow(raw: string) {
const result = parsePolicy(raw);
if (!result.ok) {
throw new Error(result.error);
}
return result;
}
describe("parsePolicy", () => {
test("parses an empty policy into an empty model", () => {
const result = parseOrThrow("");
expect(result.policy).toEqual({
groups: {},
tagOwners: {},
hosts: {},
acls: [],
ssh: [],
extra: {},
keyOrder: [],
});
expect(result.hasComments).toBe(false);
});
test("does not report comments for a policy that only has trailing commas", () => {
const result = parseOrThrow(`{
"groups": { "group:eng": ["alice@"], },
}`);
expect(result.hasComments).toBe(false);
});
test("parses HuJSON with comments and trailing commas", () => {
const result = parseOrThrow(`{
"groups": { "group:eng": ["alice@"], }, // a comment
}`);
expect(result.policy.groups).toEqual({ "group:eng": ["alice@"] });
expect(result.hasComments).toBe(true);
});
test("parses every known section", () => {
const { policy } = parseOrThrow(POLICY);
expect(policy.groups).toEqual({
"group:eng": ["alice@", "bob@"],
"group:ops": ["ops@"],
});
expect(policy.tagOwners).toEqual({ "tag:server": ["group:ops"] });
expect(policy.hosts).toEqual({ office: "100.64.0.0/24" });
expect(policy.acls).toEqual([
{ action: "accept", src: ["group:eng"], dst: ["tag:server:22"], extra: {} },
]);
expect(policy.ssh).toEqual([
{
action: "check",
src: ["group:ops"],
dst: ["tag:server"],
users: ["root"],
checkPeriod: "12h",
extra: {},
},
]);
});
test("keeps rule actions and unknown rule keys as they were written", () => {
const { policy } = parseOrThrow(`{
"acls": [
{ "action": "deny", "src": ["group:eng"], "dst": ["tag:server:22"], "srcPosture": ["posture:latest"] }
],
"ssh": [
{ "action": "reject", "src": ["group:ops"], "dst": ["tag:server"], "users": ["root"], "acceptEnv": ["TERM"] }
]
}`);
expect(policy.acls[0].action).toBe("deny");
expect(policy.acls[0].extra).toEqual({ srcPosture: ["posture:latest"] });
expect(policy.ssh[0].action).toBe("reject");
expect(policy.ssh[0].extra).toEqual({ acceptEnv: ["TERM"] });
// An action the editor does not know must survive a round trip: rewriting
// it as "accept" would widen the policy behind the operator's back.
const serialized = serializePolicy(policy);
expect(serialized).toContain('"action": "deny"');
expect(serialized).toContain('"srcPosture": ["posture:latest"]');
expect(serialized).toContain('"acceptEnv": ["TERM"]');
});
test("keeps unknown top-level keys in extra", () => {
const { policy } = parseOrThrow(POLICY);
expect(policy.extra).toEqual({
autoApprovers: { routes: { "10.0.0.0/8": ["group:ops"] } },
});
});
test("reports invalid JSON instead of throwing", () => {
const result = parsePolicy("{ not json");
expect(result.ok).toBe(false);
});
test("rejects a policy that is not an object", () => {
const result = parsePolicy("[]");
expect(result).toEqual({ ok: false, error: "The policy must be a JSON object" });
});
test("tolerates sections with the wrong shape", () => {
const { policy } = parseOrThrow(`{ "groups": "nope", "acls": { "a": 1 }, "hosts": [] }`);
expect(policy.groups).toEqual({});
expect(policy.acls).toEqual([]);
expect(policy.hosts).toEqual({});
});
});
describe("serializePolicy", () => {
test("round-trips a policy without losing data", () => {
const { policy } = parseOrThrow(POLICY);
const { policy: again } = parseOrThrow(serializePolicy(policy));
expect(again).toEqual(policy);
});
test("keeps rules on a single line and preserves key order", () => {
const { policy } = parseOrThrow(POLICY);
const output = serializePolicy(policy);
expect(output).toContain(
` { "action": "accept", "src": ["group:eng"], "dst": ["tag:server:22"] }`,
);
expect(output.indexOf(`"groups"`)).toBeLessThan(output.indexOf(`"tagOwners"`));
expect(output.endsWith("\n")).toBe(true);
});
test("omits empty sections", () => {
const { policy } = parseOrThrow(`{ "groups": { "group:eng": ["alice@"] } }`);
const output = serializePolicy(policy);
expect(output).toContain(`"groups"`);
expect(output).not.toContain(`"acls"`);
expect(output).not.toContain(`"hosts"`);
});
test("writes unknown keys back out", () => {
const { policy } = parseOrThrow(POLICY);
expect(serializePolicy(policy)).toContain(`"autoApprovers"`);
});
test("keeps the original top-level section order", () => {
const { policy } = parseOrThrow(`{
"ssh": [{ "action": "accept", "src": ["group:ops"], "dst": ["tag:server"], "users": ["root"] }],
"hosts": { "office": "100.64.0.0/24" },
"groups": { "group:eng": ["alice@"] }
}`);
const output = serializePolicy(policy);
expect(output.indexOf(`"ssh"`)).toBeLessThan(output.indexOf(`"hosts"`));
expect(output.indexOf(`"hosts"`)).toBeLessThan(output.indexOf(`"groups"`));
});
test("appends a section that did not exist before", () => {
const { policy } = parseOrThrow(`{ "hosts": { "office": "100.64.0.0/24" } }`);
const output = serializePolicy({
...policy,
groups: { "group:eng": ["alice@"] },
});
expect(output.indexOf(`"hosts"`)).toBeLessThan(output.indexOf(`"groups"`));
});
});
describe("group membership", () => {
test("finds the groups a user belongs to", () => {
const { policy } = parseOrThrow(POLICY);
expect(groupsForUser(policy, "alice")).toEqual(["group:eng"]);
expect(groupsForUser(policy, "ops")).toEqual(["group:ops"]);
expect(groupsForUser(policy, "nobody")).toEqual([]);
});
test("adds a user to a group without reordering the existing members", () => {
const { policy } = parseOrThrow(POLICY);
const next = setUserGroups(policy, "ops", ["group:eng", "group:ops"]);
expect(next.groups["group:eng"]).toEqual(["alice@", "bob@", "ops@"]);
expect(next.groups["group:ops"]).toEqual(["ops@"]);
});
test("removes a user from groups that are no longer selected", () => {
const { policy } = parseOrThrow(POLICY);
const next = setUserGroups(policy, "alice", []);
expect(next.groups["group:eng"]).toEqual(["bob@"]);
});
test("creates a group that does not exist yet", () => {
const { policy } = parseOrThrow(POLICY);
const next = setUserGroups(policy, "alice", ["group:eng", "group:new"]);
expect(next.groups["group:new"]).toEqual(["alice@"]);
});
test("is a no-op when membership does not change", () => {
const { policy } = parseOrThrow(POLICY);
const next = setUserGroups(policy, "alice", ["group:eng"]);
expect(next.groups).toEqual(policy.groups);
});
});
describe("catalog helpers", () => {
test("suggests groups, tags, hosts and users as sources", () => {
const { policy } = parseOrThrow(POLICY);
const sources = policySources(policy, ["alice", "ops"]);
expect(sources).toEqual(
expect.arrayContaining(["group:eng", "tag:server", "office", "alice@", "ops@"]),
);
});
test("suggests autogroups only where they are valid", () => {
const { policy } = parseOrThrow(POLICY);
expect(policySources(policy, [])).toContain("autogroup:member");
expect(policyDestinations(policy, [])).toContain("autogroup:internet");
expect(policyDestinations(policy, [])).not.toContain("autogroup:member");
});
test("normalizes user references", () => {
expect(asUserReference("alice")).toBe("alice@");
expect(asUserReference("alice@")).toBe("alice@");
});
});
describe("destination ports", () => {
test("appends :* when no port is given", () => {
expect(withDefaultPort("tag:web")).toBe("tag:web:*");
expect(withDefaultPort("group:eng")).toBe("group:eng:*");
expect(withDefaultPort("autogroup:internet")).toBe("autogroup:internet:*");
expect(withDefaultPort("alice@")).toBe("alice@:*");
expect(withDefaultPort("office")).toBe("office:*");
expect(withDefaultPort("*")).toBe("*:*");
expect(withDefaultPort("100.64.0.0/24")).toBe("100.64.0.0/24:*");
});
test("leaves an existing port spec alone", () => {
expect(withDefaultPort("tag:web:*")).toBe("tag:web:*");
expect(withDefaultPort("tag:web:80")).toBe("tag:web:80");
expect(withDefaultPort("tag:web:80,443")).toBe("tag:web:80,443");
expect(withDefaultPort("tag:web:8000-8080")).toBe("tag:web:8000-8080");
expect(withDefaultPort("tag:web:22,8000-8080")).toBe("tag:web:22,8000-8080");
expect(withDefaultPort("*:*")).toBe("*:*");
});
test("treats a bare IPv6 address as unported", () => {
expect(withDefaultPort("fd7a:115c:a1e0::1")).toBe("fd7a:115c:a1e0::1:*");
expect(withDefaultPort("fd7a::1")).toBe("fd7a::1:*");
expect(withDefaultPort("fd7a::/48")).toBe("fd7a::/48:*");
});
test("keeps the port of a bracketless IPv6 destination", () => {
// Headscale splits on the last colon, so this is `fd7a::1` on port 22 and
// appending `:*` would change which port the rule opens.
expect(withDefaultPort("fd7a::1:22")).toBe("fd7a::1:22");
expect(withDefaultPort("fd7a:115c:a1e0::1:80,443")).toBe("fd7a:115c:a1e0::1:80,443");
expect(hasPortSpec("fd7a::1:22")).toBe(true);
});
test("leaves a bracketed IPv6 destination alone", () => {
expect(withDefaultPort("[fd7a:115c:a1e0::1]:22")).toBe("[fd7a:115c:a1e0::1]:22");
});
test("trims and ignores empty input", () => {
expect(withDefaultPort(" tag:web ")).toBe("tag:web:*");
expect(withDefaultPort(" ")).toBe("");
});
test("reports whether a port spec is present", () => {
expect(hasPortSpec("tag:web:80")).toBe(true);
expect(hasPortSpec("group:eng:*")).toBe(true);
expect(hasPortSpec("100.64.0.1:22")).toBe(true);
expect(hasPortSpec("tag:web")).toBe(false);
expect(hasPortSpec("fd7a::1")).toBe(false);
expect(hasPortSpec("alice@")).toBe(false);
});
});
describe("validation", () => {
test("accepts well-formed names", () => {
expect(isValidGroupName("group:eng-team")).toBe(true);
expect(isValidTagName("tag:web-01")).toBe(true);
expect(isValidHostName("office-2")).toBe(true);
});
test("rejects malformed names", () => {
expect(isValidGroupName("eng")).toBe(false);
expect(isValidGroupName("group:")).toBe(false);
expect(isValidGroupName("group:Eng")).toBe(false);
expect(isValidTagName("group:eng")).toBe(false);
expect(isValidHostName("tag:web")).toBe(false);
});
});
+10 -2
View File
@@ -41,8 +41,16 @@ describe("extractTagOwnerTags", () => {
).toEqual(["tag:prod", "tag:server"]);
});
test("ignores invalid policies", () => {
expect(extractTagOwnerTags("not-json")).toEqual([]);
test("returns undefined when the policy cannot be read or parsed", () => {
// An unreadable policy is not the same as one declaring no tags: callers
// use `undefined` to keep the tag dialog from flagging every tag.
expect(extractTagOwnerTags(undefined)).toBeUndefined();
expect(extractTagOwnerTags("not-json")).toBeUndefined();
});
test("returns an empty list when the policy declares no tags", () => {
expect(extractTagOwnerTags("")).toEqual([]);
expect(extractTagOwnerTags('{ "groups": { "group:eng": ["alice@"] } }')).toEqual([]);
});
});