mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
Merge pull request #478 from drifterza/fix/issue-453-self-service-preauth-keys
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user