mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 08:49:30 +00:00
feat(ui): structured editor for ACL rules, tags and groups (#608)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user