Merge pull request #478 from drifterza/fix/issue-453-self-service-preauth-keys

This commit is contained in:
Aarnav Tale
2026-03-07 14:50:12 -05:00
committed by GitHub
5 changed files with 281 additions and 133 deletions
+33 -3
View File
@@ -6,16 +6,18 @@ import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) { export async function authKeysAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.generate_authkeys); const api = context.hsApi.getRuntimeClient(session.api_key);
if (!check) { const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys);
const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) {
throw data("You do not have permission to manage pre-auth keys", { throw data("You do not have permission to manage pre-auth keys", {
status: 403, status: 403,
}); });
} }
const formData = await request.formData(); const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get("action_id")?.toString(); const action = formData.get("action_id")?.toString();
if (!action) { if (!action) {
throw data("Missing `action_id` in the form data.", { throw data("Missing `action_id` in the form data.", {
@@ -38,6 +40,20 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
}); });
} }
// Only allow self-service users to create keys for themselves
if (!canGenerateAny && canGenerateOwn && user) {
const [targetUser] = await api.getUsers(user);
if (!targetUser) {
return data("User not found.", { status: 404 });
}
const targetSubject = targetUser.providerId?.split("/").pop();
if (targetSubject !== session.user.subject) {
throw data("You can only create pre-auth keys for your own user", {
status: 403,
});
}
}
const expiry = formData.get("expiry")?.toString(); const expiry = formData.get("expiry")?.toString();
if (!expiry) { if (!expiry) {
return data("Missing `expiry` in the form data.", { return data("Missing `expiry` in the form data.", {
@@ -88,6 +104,20 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
}); });
} }
// Only allow self-service users to expire their own keys
if (!canGenerateAny && canGenerateOwn) {
const [targetUser] = await api.getUsers(user);
if (!targetUser) {
return data("User not found.", { status: 404 });
}
const targetSubject = targetUser.providerId?.split("/").pop();
if (targetSubject !== session.user.subject) {
throw data("You can only expire pre-auth keys for your own user", {
status: 403,
});
}
}
await api.expirePreAuthKey(user, key); await api.expirePreAuthKey(user, key);
return data("Pre-auth key expired"); return data("Pre-auth key expired");
} }
@@ -16,16 +16,29 @@ import toast from "~/utils/toast";
interface AddAuthKeyProps { interface AddAuthKeyProps {
users: User[]; users: User[];
url: string; url: string;
selfServiceOnly: boolean;
currentSubject: string;
} }
export default function AddAuthKey({ users, url }: AddAuthKeyProps) { function findCurrentUser(users: User[], subject: string): User | undefined {
return users.find((u) => u.providerId?.split("/").pop() === subject);
}
export default function AddAuthKey({
users,
url,
selfServiceOnly,
currentSubject,
}: AddAuthKeyProps) {
const fetcher = useFetcher(); const fetcher = useFetcher();
const submittingRef = useRef(false); const submittingRef = useRef(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [reusable, setReusable] = useState(false); const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false); const [ephemeral, setEphemeral] = useState(false);
const [tagOnly, setTagOnly] = useState(false); const [tagOnly, setTagOnly] = useState(false);
const [userId, setUserId] = useState<Key | null>(users[0]?.id); const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
const [userId, setUserId] = useState<Key | null>(availableUsers[0]?.id);
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const createdKey = fetcher.data?.success ? fetcher.data.key : null; const createdKey = fetcher.data?.success ? fetcher.data.key : null;
@@ -41,7 +54,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
setReusable(false); setReusable(false);
setEphemeral(false); setEphemeral(false);
setTagOnly(false); setTagOnly(false);
setUserId(users[0]?.id); setUserId(availableUsers[0]?.id);
setTags(""); setTags("");
fetcher.data = undefined; fetcher.data = undefined;
} }
@@ -107,30 +120,38 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
> >
<Dialog.Title>Generate auth key</Dialog.Title> <Dialog.Title>Generate auth key</Dialog.Title>
<div className="mb-4 flex items-center justify-between gap-2"> {!selfServiceOnly && (
<div> <div className="mb-4 flex items-center justify-between gap-2">
<Dialog.Text className="font-semibold">Tag-only key</Dialog.Text> <div>
<Dialog.Text className="text-sm"> <Dialog.Text className="font-semibold">Tag-only key</Dialog.Text>
Create a key owned by ACL tags instead of a user. <Dialog.Text className="text-sm">
</Dialog.Text> Create a key owned by ACL tags instead of a user.
</Dialog.Text>
</div>
<Switch
defaultSelected={tagOnly}
label="Tag-only"
onChange={() => setTagOnly(!tagOnly)}
/>
</div> </div>
<Switch )}
defaultSelected={tagOnly}
label="Tag-only"
onChange={() => setTagOnly(!tagOnly)}
/>
</div>
{!tagOnly && ( {!tagOnly && (
<Select <Select
className="mb-2" className="mb-2"
description="Machines will belong to this user when they authenticate." description={
selfServiceOnly
? "You can only create keys for your own user."
: "Machines will belong to this user when they authenticate."
}
isDisabled={selfServiceOnly}
isRequired isRequired
label="User" label="User"
onSelectionChange={(value) => setUserId(value)} onSelectionChange={(value) => setUserId(value)}
placeholder="Select a user" placeholder="Select a user"
selectedKey={userId}
> >
{users.map((user) => ( {availableUsers.map((user) => (
<Select.Item key={user.id}> <Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id} {user.name || user.displayName || user.email || user.id}
</Select.Item> </Select.Item>
+16 -5
View File
@@ -2,14 +2,15 @@ import { FileKey2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link as RemixLink } from "react-router"; import { Link as RemixLink } from "react-router";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import Code from "~/components/Code"; import Code from "~/components/Code";
import Link from "~/components/Link"; import Link from "~/components/Link";
import Notice from "~/components/Notice"; import Notice from "~/components/Notice";
import Select from "~/components/Select"; import Select from "~/components/Select";
import TableList from "~/components/TableList"; import TableList from "~/components/TableList";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import log from "~/utils/log"; import log from "~/utils/log";
import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user"; import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user";
@@ -82,11 +83,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(({ user, error }) => ({ user, error })); .map(({ user, error }) => ({ user, error }));
} }
const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys);
const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys);
return { return {
keys, keys,
missing, missing,
users, users,
access: await context.sessions.check(request, Capabilities.generate_authkeys), access: canGenerateAny || canGenerateOwn,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
currentSubject: session.user.subject,
url: context.config.headscale.public_url ?? context.config.headscale.url, url: context.config.headscale.public_url ?? context.config.headscale.url,
}; };
} }
@@ -95,7 +101,7 @@ export const action = authKeysAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral"; type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({ export default function Page({
loaderData: { keys, missing, users, url, access }, loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
}: Route.ComponentProps) { }: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState("__headplane_all"); const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>("active"); const [status, setStatus] = useState<Status>("active");
@@ -193,7 +199,12 @@ export default function Page({
Tailscale documentation Tailscale documentation
</Link> </Link>
</p> </p>
<AddAuthKey url={url} users={users} /> <AddAuthKey
currentSubject={currentSubject}
selfServiceOnly={selfServiceOnly}
url={url}
users={users}
/>
<div className="mt-4 flex items-center gap-4"> <div className="mt-4 flex items-center gap-4">
<Select <Select
className="w-full" className="w-full"
+112 -108
View File
@@ -1,144 +1,148 @@
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities]; export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
export const Capabilities = { export const Capabilities = {
// Can access the admin console // Can access the admin console
ui_access: 1 << 0, ui_access: 1 << 0,
// Read tailnet policy file (unimplemented) // Read tailnet policy file (unimplemented)
read_policy: 1 << 1, read_policy: 1 << 1,
// Write tailnet policy file (unimplemented) // Write tailnet policy file (unimplemented)
write_policy: 1 << 2, write_policy: 1 << 2,
// Read network configurations // Read network configurations
read_network: 1 << 3, read_network: 1 << 3,
// Write network configurations, for example, enable MagicDNS, split DNS, // Write network configurations, for example, enable MagicDNS, split DNS,
// make subnet, or allow a node to be an exit node, enable HTTPS // make subnet, or allow a node to be an exit node, enable HTTPS
write_network: 1 << 4, write_network: 1 << 4,
// Read feature configuration (unimplemented) // Read feature configuration (unimplemented)
read_feature: 1 << 5, read_feature: 1 << 5,
// Write feature configuration, for example, enable Taildrop (unimplemented) // Write feature configuration, for example, enable Taildrop (unimplemented)
write_feature: 1 << 6, write_feature: 1 << 6,
// Configure user & group provisioning // Configure user & group provisioning
configure_iam: 1 << 7, configure_iam: 1 << 7,
// Read machines, for example, see machine names and status // Read machines, for example, see machine names and status
read_machines: 1 << 8, read_machines: 1 << 8,
// Write machines, for example, approve, rename, and remove machines // Write machines, for example, approve, rename, and remove machines
write_machines: 1 << 9, write_machines: 1 << 9,
// Read users and user roles // Read users and user roles
read_users: 1 << 10, read_users: 1 << 10,
// Write users and user roles, for example, remove users, // Write users and user roles, for example, remove users,
// approve users, make Admin // approve users, make Admin
write_users: 1 << 11, write_users: 1 << 11,
// Can generate authkeys (unimplemented) // Can generate authkeys for any user
generate_authkeys: 1 << 12, generate_authkeys: 1 << 12,
// Can use any tag (without being tag owner) (unimplemented) // Can generate authkeys for own user only
use_tags: 1 << 13, generate_own_authkeys: 1 << 16,
// Write tailnet name (unimplemented) // Can use any tag (without being tag owner) (unimplemented)
write_tailnet: 1 << 14, use_tags: 1 << 13,
// Owner flag // Write tailnet name (unimplemented)
owner: 1 << 15, write_tailnet: 1 << 14,
// Owner flag
owner: 1 << 15,
} as const; } as const;
export type Roles = [keyof typeof Roles]; export type Roles = [keyof typeof Roles];
export const Roles = { export const Roles = {
owner: owner:
Capabilities.ui_access | Capabilities.ui_access |
Capabilities.read_policy | Capabilities.read_policy |
Capabilities.write_policy | Capabilities.write_policy |
Capabilities.read_network | Capabilities.read_network |
Capabilities.write_network | Capabilities.write_network |
Capabilities.read_feature | Capabilities.read_feature |
Capabilities.write_feature | Capabilities.write_feature |
Capabilities.configure_iam | Capabilities.configure_iam |
Capabilities.read_machines | Capabilities.read_machines |
Capabilities.write_machines | Capabilities.write_machines |
Capabilities.read_users | Capabilities.read_users |
Capabilities.write_users | Capabilities.write_users |
Capabilities.generate_authkeys | Capabilities.generate_authkeys |
Capabilities.use_tags | Capabilities.use_tags |
Capabilities.write_tailnet | Capabilities.write_tailnet |
Capabilities.owner, Capabilities.owner,
admin: admin:
Capabilities.ui_access | Capabilities.ui_access |
Capabilities.read_policy | Capabilities.read_policy |
Capabilities.write_policy | Capabilities.write_policy |
Capabilities.read_network | Capabilities.read_network |
Capabilities.write_network | Capabilities.write_network |
Capabilities.read_feature | Capabilities.read_feature |
Capabilities.write_feature | Capabilities.write_feature |
Capabilities.configure_iam | Capabilities.configure_iam |
Capabilities.read_machines | Capabilities.read_machines |
Capabilities.write_machines | Capabilities.write_machines |
Capabilities.read_users | Capabilities.read_users |
Capabilities.write_users | Capabilities.write_users |
Capabilities.generate_authkeys | Capabilities.generate_authkeys |
Capabilities.use_tags | Capabilities.use_tags |
Capabilities.write_tailnet, Capabilities.write_tailnet,
network_admin: network_admin:
Capabilities.ui_access | Capabilities.ui_access |
Capabilities.read_policy | Capabilities.read_policy |
Capabilities.write_policy | Capabilities.write_policy |
Capabilities.read_network | Capabilities.read_network |
Capabilities.write_network | Capabilities.write_network |
Capabilities.read_feature | Capabilities.read_feature |
Capabilities.read_machines | Capabilities.read_machines |
Capabilities.read_users | Capabilities.read_users |
Capabilities.generate_authkeys | Capabilities.generate_authkeys |
Capabilities.use_tags | Capabilities.use_tags |
Capabilities.write_tailnet, Capabilities.write_tailnet,
it_admin: it_admin:
Capabilities.ui_access | Capabilities.ui_access |
Capabilities.read_policy | Capabilities.read_policy |
Capabilities.read_network | Capabilities.read_network |
Capabilities.read_feature | Capabilities.read_feature |
Capabilities.write_feature | Capabilities.write_feature |
Capabilities.configure_iam | Capabilities.configure_iam |
Capabilities.read_machines | Capabilities.read_machines |
Capabilities.write_machines | Capabilities.write_machines |
Capabilities.read_users | Capabilities.read_users |
Capabilities.write_users | Capabilities.write_users |
Capabilities.generate_authkeys, Capabilities.generate_authkeys,
auditor: auditor:
Capabilities.ui_access | Capabilities.ui_access |
Capabilities.read_policy | Capabilities.read_policy |
Capabilities.read_network | Capabilities.read_network |
Capabilities.read_feature | Capabilities.read_feature |
Capabilities.read_machines | Capabilities.read_machines |
Capabilities.read_users, Capabilities.read_users |
Capabilities.generate_own_authkeys,
// Default role for new users with 0 capabilities on the UI side of things // Default role for new users with 0 capabilities on the UI side of things
member: 0, member: 0,
} as const; } as const;
export type Role = keyof typeof Roles; export type Role = keyof typeof Roles;
export type Capability = keyof typeof Capabilities; export type Capability = keyof typeof Capabilities;
export function hasCapability(role: Role, capability: Capability): boolean { export function hasCapability(role: Role, capability: Capability): boolean {
return (Roles[role] & Capabilities[capability]) !== 0; return (Roles[role] & Capabilities[capability]) !== 0;
} }
export function getRoleFromCapabilities(capabilities: Capabilities): Role { export function getRoleFromCapabilities(capabilities: Capabilities): Role {
const iterable = Roles as Record<string, Capabilities>; const iterable = Roles as Record<string, Capabilities>;
for (const role in iterable) { for (const role in iterable) {
if (iterable[role] === capabilities) { if (iterable[role] === capabilities) {
return role as Role; return role as Role;
} }
} }
return 'member'; return "member";
} }
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, test } from "vitest";
import { Capabilities, hasCapability, Roles } from "~/server/web/roles";
describe("Self-service pre-auth keys", () => {
describe("Capabilities", () => {
test("generate_own_authkeys capability exists", () => {
expect(Capabilities.generate_own_authkeys).toBeDefined();
expect(typeof Capabilities.generate_own_authkeys).toBe("number");
});
test("generate_own_authkeys is distinct from generate_authkeys", () => {
expect(Capabilities.generate_own_authkeys).not.toBe(Capabilities.generate_authkeys);
});
});
describe("Auditor role", () => {
test("auditor has generate_own_authkeys", () => {
expect(hasCapability("auditor", "generate_own_authkeys")).toBe(true);
});
test("auditor does not have generate_authkeys", () => {
expect(hasCapability("auditor", "generate_authkeys")).toBe(false);
});
test("auditor has read permissions", () => {
expect(hasCapability("auditor", "read_machines")).toBe(true);
expect(hasCapability("auditor", "read_users")).toBe(true);
expect(hasCapability("auditor", "read_policy")).toBe(true);
});
});
describe("Admin roles retain full access", () => {
test("owner has generate_authkeys", () => {
expect(hasCapability("owner", "generate_authkeys")).toBe(true);
});
test("admin has generate_authkeys", () => {
expect(hasCapability("admin", "generate_authkeys")).toBe(true);
});
test("it_admin has generate_authkeys", () => {
expect(hasCapability("it_admin", "generate_authkeys")).toBe(true);
});
test("network_admin has generate_authkeys", () => {
expect(hasCapability("network_admin", "generate_authkeys")).toBe(true);
});
});
describe("Member role", () => {
test("member has no capabilities", () => {
expect(Roles.member).toBe(0);
});
test("member does not have generate_own_authkeys", () => {
expect(hasCapability("member", "generate_own_authkeys")).toBe(false);
});
});
});
describe("providerId subject extraction", () => {
function extractSubject(providerId: string | undefined): string | undefined {
return providerId?.split("/").pop();
}
test("extracts subject from oidc providerId", () => {
expect(extractSubject("oidc/abc123")).toBe("abc123");
});
test("extracts subject from nested providerId", () => {
expect(extractSubject("provider/tenant/user123")).toBe("user123");
});
test("handles single component providerId", () => {
expect(extractSubject("subject")).toBe("subject");
});
test("returns undefined for undefined providerId", () => {
expect(extractSubject(undefined)).toBeUndefined();
});
});