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) {
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", {
status: 403,
});
}
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get("action_id")?.toString();
if (!action) {
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();
if (!expiry) {
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);
return data("Pre-auth key expired");
}
@@ -16,16 +16,29 @@ import toast from "~/utils/toast";
interface AddAuthKeyProps {
users: User[];
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 submittingRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = 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 createdKey = fetcher.data?.success ? fetcher.data.key : null;
@@ -41,7 +54,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
setReusable(false);
setEphemeral(false);
setTagOnly(false);
setUserId(users[0]?.id);
setUserId(availableUsers[0]?.id);
setTags("");
fetcher.data = undefined;
}
@@ -107,30 +120,38 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
>
<Dialog.Title>Generate auth key</Dialog.Title>
<div className="mb-4 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Tag-only key</Dialog.Text>
<Dialog.Text className="text-sm">
Create a key owned by ACL tags instead of a user.
</Dialog.Text>
{!selfServiceOnly && (
<div className="mb-4 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Tag-only key</Dialog.Text>
<Dialog.Text className="text-sm">
Create a key owned by ACL tags instead of a user.
</Dialog.Text>
</div>
<Switch
defaultSelected={tagOnly}
label="Tag-only"
onChange={() => setTagOnly(!tagOnly)}
/>
</div>
<Switch
defaultSelected={tagOnly}
label="Tag-only"
onChange={() => setTagOnly(!tagOnly)}
/>
</div>
)}
{!tagOnly && (
<Select
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
label="User"
onSelectionChange={(value) => setUserId(value)}
placeholder="Select a user"
selectedKey={userId}
>
{users.map((user) => (
{availableUsers.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
+16 -5
View File
@@ -2,14 +2,15 @@ import { FileKey2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Link as RemixLink } from "react-router";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import Code from "~/components/Code";
import Link from "~/components/Link";
import Notice from "~/components/Notice";
import Select from "~/components/Select";
import TableList from "~/components/TableList";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import log from "~/utils/log";
import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user";
@@ -82,11 +83,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.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 {
keys,
missing,
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,
};
}
@@ -95,7 +101,7 @@ export const action = authKeysAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
loaderData: { keys, missing, users, url, access },
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>("active");
@@ -193,7 +199,12 @@ export default function Page({
Tailscale documentation
</Link>
</p>
<AddAuthKey url={url} users={users} />
<AddAuthKey
currentSubject={currentSubject}
selfServiceOnly={selfServiceOnly}
url={url}
users={users}
/>
<div className="mt-4 flex items-center gap-4">
<Select
className="w-full"
+112 -108
View File
@@ -1,144 +1,148 @@
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
export const Capabilities = {
// Can access the admin console
ui_access: 1 << 0,
// Can access the admin console
ui_access: 1 << 0,
// Read tailnet policy file (unimplemented)
read_policy: 1 << 1,
// Read tailnet policy file (unimplemented)
read_policy: 1 << 1,
// Write tailnet policy file (unimplemented)
write_policy: 1 << 2,
// Write tailnet policy file (unimplemented)
write_policy: 1 << 2,
// Read network configurations
read_network: 1 << 3,
// Read network configurations
read_network: 1 << 3,
// Write network configurations, for example, enable MagicDNS, split DNS,
// make subnet, or allow a node to be an exit node, enable HTTPS
write_network: 1 << 4,
// Write network configurations, for example, enable MagicDNS, split DNS,
// make subnet, or allow a node to be an exit node, enable HTTPS
write_network: 1 << 4,
// Read feature configuration (unimplemented)
read_feature: 1 << 5,
// Read feature configuration (unimplemented)
read_feature: 1 << 5,
// Write feature configuration, for example, enable Taildrop (unimplemented)
write_feature: 1 << 6,
// Write feature configuration, for example, enable Taildrop (unimplemented)
write_feature: 1 << 6,
// Configure user & group provisioning
configure_iam: 1 << 7,
// Configure user & group provisioning
configure_iam: 1 << 7,
// Read machines, for example, see machine names and status
read_machines: 1 << 8,
// Read machines, for example, see machine names and status
read_machines: 1 << 8,
// Write machines, for example, approve, rename, and remove machines
write_machines: 1 << 9,
// Write machines, for example, approve, rename, and remove machines
write_machines: 1 << 9,
// Read users and user roles
read_users: 1 << 10,
// Read users and user roles
read_users: 1 << 10,
// Write users and user roles, for example, remove users,
// approve users, make Admin
write_users: 1 << 11,
// Write users and user roles, for example, remove users,
// approve users, make Admin
write_users: 1 << 11,
// Can generate authkeys (unimplemented)
generate_authkeys: 1 << 12,
// Can generate authkeys for any user
generate_authkeys: 1 << 12,
// Can use any tag (without being tag owner) (unimplemented)
use_tags: 1 << 13,
// Can generate authkeys for own user only
generate_own_authkeys: 1 << 16,
// Write tailnet name (unimplemented)
write_tailnet: 1 << 14,
// Can use any tag (without being tag owner) (unimplemented)
use_tags: 1 << 13,
// Owner flag
owner: 1 << 15,
// Write tailnet name (unimplemented)
write_tailnet: 1 << 14,
// Owner flag
owner: 1 << 15,
} as const;
export type Roles = [keyof typeof Roles];
export const Roles = {
owner:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet |
Capabilities.owner,
owner:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet |
Capabilities.owner,
admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
network_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
network_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
it_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys,
it_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys,
auditor:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users,
auditor:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_own_authkeys,
// Default role for new users with 0 capabilities on the UI side of things
member: 0,
// Default role for new users with 0 capabilities on the UI side of things
member: 0,
} as const;
export type Role = keyof typeof Roles;
export type Capability = keyof typeof Capabilities;
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 {
const iterable = Roles as Record<string, Capabilities>;
for (const role in iterable) {
if (iterable[role] === capabilities) {
return role as Role;
}
}
const iterable = Roles as Record<string, Capabilities>;
for (const role in iterable) {
if (iterable[role] === capabilities) {
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();
});
});