feat: make machine actions permission locked

This commit is contained in:
Aarnav Tale 2025-04-03 00:14:51 -04:00
parent 259d150fc4
commit 58cc7b742c
8 changed files with 135 additions and 49 deletions

View File

@ -16,6 +16,7 @@ import cn from '~/utils/cn';
interface MenuProps extends MenuTriggerProps { interface MenuProps extends MenuTriggerProps {
placement?: Placement; placement?: Placement;
isDisabled?: boolean;
children: [ children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>, React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>, React.ReactElement<MenuPanelProps>,
@ -23,8 +24,9 @@ interface MenuProps extends MenuTriggerProps {
} }
// TODO: onAction is called twice for some reason? // TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) { function Menu(props: MenuProps) {
const { placement = 'bottom' } = props; const { placement = 'bottom', isDisabled } = props;
const state = useMenuTriggerState(props); const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null); const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>( const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
@ -40,6 +42,7 @@ function Menu(props: MenuProps) {
<div> <div>
{cloneElement(button, { {cloneElement(button, {
...menuTriggerProps, ...menuTriggerProps,
isDisabled: isDisabled,
ref, ref,
})} })}
{state.isOpen && ( {state.isOpen && (

View File

@ -18,6 +18,7 @@ interface Props {
isAgent?: boolean; isAgent?: boolean;
magic?: string; magic?: string;
stats?: HostInfo; stats?: HostInfo;
isDisabled?: boolean;
} }
export default function MachineRow({ export default function MachineRow({
@ -27,6 +28,7 @@ export default function MachineRow({
isAgent, isAgent,
magic, magic,
stats, stats,
isDisabled,
}: Props) { }: Props) {
const expired = const expired =
machine.expiry === '0001-01-01 00:00:00' || machine.expiry === '0001-01-01 00:00:00' ||
@ -191,6 +193,7 @@ export default function MachineRow({
routes={routes} routes={routes}
users={users} users={users}
magic={magic} magic={magic}
isDisabled={isDisabled}
/> />
</td> </td>
</tr> </tr>

View File

@ -16,6 +16,7 @@ interface MenuProps {
users: User[]; users: User[];
magic?: string; magic?: string;
isFullButton?: boolean; isFullButton?: boolean;
isDisabled?: boolean;
} }
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null; type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
@ -26,6 +27,7 @@ export default function MachineMenu({
magic, magic,
users, users,
isFullButton, isFullButton,
isDisabled,
}: MenuProps) { }: MenuProps) {
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
@ -96,7 +98,7 @@ export default function MachineMenu({
/> />
)} )}
<Menu> <Menu isDisabled={isDisabled}>
{isFullButton ? ( {isFullButton ? (
<Menu.Button className="flex items-center gap-x-2"> <Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" /> <Cog className="h-5" />

View File

@ -8,12 +8,13 @@ import Menu from '~/components/Menu';
import Select from '~/components/Select'; import Select from '~/components/Select';
import type { User } from '~/types'; import type { User } from '~/types';
export interface NewProps { export interface NewMachineProps {
server: string; server: string;
users: User[]; users: User[];
isDisabled?: boolean;
} }
export default function New(data: NewProps) { export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false); const [pushDialog, setPushDialog] = useState(false);
const [mkey, setMkey] = useState(''); const [mkey, setMkey] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
@ -25,11 +26,8 @@ export default function New(data: NewProps) {
<Dialog.Title>Register Machine Key</Dialog.Title> <Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4"> <Dialog.Text className="mb-4">
The machine key is given when you run{' '} The machine key is given when you run{' '}
<Code isCopyable> <Code isCopyable>tailscale up --login-server={data.server}</Code> on
tailscale up --login-server= your device.
{data.server}
</Code>{' '}
on your device.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="_method" value="register" /> <input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" /> <input type="hidden" name="id" value="_" />
@ -53,7 +51,7 @@ export default function New(data: NewProps) {
</Select> </Select>
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
<Menu> <Menu isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button> <Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel <Menu.Panel
onAction={(key) => { onAction={(key) => {

View File

@ -1,43 +1,65 @@
import type { ActionFunctionArgs } from 'react-router'; import type { ActionFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types';
import log from '~/utils/log'; import log from '~/utils/log';
import { send } from '~/utils/res'; import { data400, data403, data404, send } from '~/utils/res';
// TODO: Turn this into the same thing as dns-actions like machine-actions!!! // TODO: Clean this up like dns-actions and user-actions
export async function menuAction({ export async function machineAction({
request, request,
context, context,
}: ActionFunctionArgs<LoadContext>) { }: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const data = await request.formData(); const check = await context.sessions.check(
if (!data.has('_method') || !data.has('id')) { request,
return send( Capabilities.write_machines,
{ message: 'No method or ID provided' },
{
status: 400,
},
); );
const apiKey = session.get('api_key')!;
const formData = await request.formData();
// TODO: Rename this to 'action_id' and 'node_id'
const action = formData.get('_method')?.toString();
const nodeId = formData.get('id')?.toString();
if (!action || !nodeId) {
return data400('Missing required parameters: _method and id');
} }
const id = String(data.get('id')); const { nodes } = await context.client.get<{ nodes: Machine[] }>(
const method = String(data.get('_method')); 'v1/node',
apiKey,
);
switch (method) { const node = nodes.find((node) => node.id === nodeId);
if (!node) {
return data404(`Node with ID ${nodeId} not found`);
}
const subject = session.get('user')!.subject;
if (node.user.providerId?.split('/').pop() !== subject) {
if (!check) {
return data403('You do not have permission to act on this machine');
}
}
// TODO: Split up into methods
switch (action) {
case 'delete': { case 'delete': {
await context.client.delete(`v1/node/${id}`, session.get('api_key')!); await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!);
return { message: 'Machine removed' }; return { message: 'Machine removed' };
} }
case 'expire': { case 'expire': {
await context.client.post( await context.client.post(
`v1/node/${id}/expire`, `v1/node/${nodeId}/expire`,
session.get('api_key')!, session.get('api_key')!,
); );
return { message: 'Machine expired' }; return { message: 'Machine expired' };
} }
case 'rename': { case 'rename': {
if (!data.has('name')) { if (!formData.has('name')) {
return send( return send(
{ message: 'No name provided' }, { message: 'No name provided' },
{ {
@ -46,16 +68,16 @@ export async function menuAction({
); );
} }
const name = String(data.get('name')); const name = String(formData.get('name'));
await context.client.post( await context.client.post(
`v1/node/${id}/rename/${name}`, `v1/node/${nodeId}/rename/${name}`,
session.get('api_key')!, session.get('api_key')!,
); );
return { message: 'Machine renamed' }; return { message: 'Machine renamed' };
} }
case 'routes': { case 'routes': {
if (!data.has('route') || !data.has('enabled')) { if (!formData.has('route') || !formData.has('enabled')) {
return send( return send(
{ message: 'No route or enabled provided' }, { message: 'No route or enabled provided' },
{ {
@ -64,8 +86,8 @@ export async function menuAction({
); );
} }
const route = String(data.get('route')); const route = String(formData.get('route'));
const enabled = data.get('enabled') === 'true'; const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable'; const postfix = enabled ? 'enable' : 'disable';
await context.client.post( await context.client.post(
@ -76,7 +98,7 @@ export async function menuAction({
} }
case 'exit-node': { case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) { if (!formData.has('routes') || !formData.has('enabled')) {
return send( return send(
{ message: 'No route or enabled provided' }, { message: 'No route or enabled provided' },
{ {
@ -85,8 +107,8 @@ export async function menuAction({
); );
} }
const routes = data.get('routes')?.toString().split(',') ?? []; const routes = formData.get('routes')?.toString().split(',') ?? [];
const enabled = data.get('enabled') === 'true'; const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable'; const postfix = enabled ? 'enable' : 'disable';
await Promise.all( await Promise.all(
@ -102,7 +124,7 @@ export async function menuAction({
} }
case 'move': { case 'move': {
if (!data.has('to')) { if (!formData.has('to')) {
return send( return send(
{ message: 'No destination provided' }, { message: 'No destination provided' },
{ {
@ -111,22 +133,22 @@ export async function menuAction({
); );
} }
const to = String(data.get('to')); const to = String(formData.get('to'));
try { try {
await context.client.post( await context.client.post(
`v1/node/${id}/user`, `v1/node/${nodeId}/user`,
session.get('api_key')!, session.get('api_key')!,
{ {
user: to, user: to,
}, },
); );
return { message: `Moved node ${id} to ${to}` }; return { message: `Moved node ${nodeId} to ${to}` };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return send( return send(
{ message: `Failed to move node ${id} to ${to}` }, { message: `Failed to move node ${nodeId} to ${to}` },
{ {
status: 500, status: 500,
}, },
@ -136,7 +158,7 @@ export async function menuAction({
case 'tags': { case 'tags': {
const tags = const tags =
data formData
.get('tags') .get('tags')
?.toString() ?.toString()
.split(',') .split(',')
@ -144,7 +166,7 @@ export async function menuAction({
try { try {
await context.client.post( await context.client.post(
`v1/node/${id}/tags`, `v1/node/${nodeId}/tags`,
session.get('api_key')!, session.get('api_key')!,
{ {
tags, tags,
@ -164,8 +186,8 @@ export async function menuAction({
} }
case 'register': { case 'register': {
const key = data.get('mkey')?.toString(); const key = formData.get('mkey')?.toString();
const user = data.get('user')?.toString(); const user = formData.get('user')?.toString();
if (!key) { if (!key) {
return send( return send(

View File

@ -12,9 +12,9 @@ import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types'; import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { menuAction } from './action';
import MenuOptions from './components/menu'; import MenuOptions from './components/menu';
import Routes from './dialogs/routes'; import Routes from './dialogs/routes';
import { machineAction } from './machine-actions';
export async function loader({ export async function loader({
request, request,
@ -59,7 +59,7 @@ export async function loader({
} }
export async function action(request: ActionFunctionArgs) { export async function action(request: ActionFunctionArgs) {
return menuAction(request); return machineAction(request);
} }
export default function Page() { export default function Page() {

View File

@ -6,17 +6,40 @@ import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip'; import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Machine, Route, User } from '~/types'; import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { menuAction } from './action'; import MachineRow from './components/machine-row';
import MachineRow from './components/machine';
import NewMachine from './dialogs/new'; import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
export async function loader({ export async function loader({
request, request,
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
const check = await context.sessions.check(
request,
Capabilities.read_machines,
);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_machines,
);
const [machines, routes, users] = await Promise.all([ const [machines, routes, users] = await Promise.all([
context.client.get<{ nodes: Machine[] }>( context.client.get<{ nodes: Machine[] }>(
'v1/node', 'v1/node',
@ -45,11 +68,13 @@ export async function loader({
publicServer: context.config.headscale.public_url, publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(), agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)), stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
writable: writablePermission,
subject: user.subject,
}; };
} }
export async function action(request: ActionFunctionArgs) { export async function action(request: ActionFunctionArgs) {
return menuAction(request); return machineAction(request);
} }
export default function Page() { export default function Page() {
@ -73,6 +98,7 @@ export default function Page() {
<NewMachine <NewMachine
server={data.publicServer ?? data.server} server={data.publicServer ?? data.server}
users={data.users} users={data.users}
isDisabled={!data.writable}
/> />
</div> </div>
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
@ -123,6 +149,11 @@ export default function Page() {
// This is useful for when there are no agents configured // This is useful for when there are no agents configured
isAgent={data.agents?.includes(machine.id)} isAgent={data.agents?.includes(machine.id)}
stats={data.stats?.[machine.nodeKey]} stats={data.stats?.[machine.nodeKey]}
isDisabled={
data.writable
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
}
/> />
))} ))}
</tbody> </tbody>

View File

@ -7,3 +7,30 @@ export function send<T>(payload: T, init?: number | ResponseInit) {
export function send401<T>(payload: T) { export function send401<T>(payload: T) {
return data(payload, { status: 401 }); return data(payload, { status: 401 });
} }
export function data400(message: string) {
return data(
{
success: false,
message,
},
{ status: 400 },
);
}
export function data403(message: string) {
return data({
success: false,
message,
});
}
export function data404(message: string) {
return data(
{
success: false,
message,
},
{ status: 404 },
);
}