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

View File

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

View File

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

View File

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

View File

@ -1,43 +1,65 @@
import type { ActionFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types';
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!!!
export async function menuAction({
// TODO: Clean this up like dns-actions and user-actions
export async function machineAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send(
{ message: 'No method or ID provided' },
{
status: 400,
},
const check = await context.sessions.check(
request,
Capabilities.write_machines,
);
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 method = String(data.get('_method'));
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'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': {
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' };
}
case 'expire': {
await context.client.post(
`v1/node/${id}/expire`,
`v1/node/${nodeId}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
}
case 'rename': {
if (!data.has('name')) {
if (!formData.has('name')) {
return send(
{ 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(
`v1/node/${id}/rename/${name}`,
`v1/node/${nodeId}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
if (!formData.has('route') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
@ -64,8 +86,8 @@ export async function menuAction({
);
}
const route = String(data.get('route'));
const enabled = data.get('enabled') === 'true';
const route = String(formData.get('route'));
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await context.client.post(
@ -76,7 +98,7 @@ export async function menuAction({
}
case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
if (!formData.has('routes') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
@ -85,8 +107,8 @@ export async function menuAction({
);
}
const routes = data.get('routes')?.toString().split(',') ?? [];
const enabled = data.get('enabled') === 'true';
const routes = formData.get('routes')?.toString().split(',') ?? [];
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(
@ -102,7 +124,7 @@ export async function menuAction({
}
case 'move': {
if (!data.has('to')) {
if (!formData.has('to')) {
return send(
{ 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 {
await context.client.post(
`v1/node/${id}/user`,
`v1/node/${nodeId}/user`,
session.get('api_key')!,
{
user: to,
},
);
return { message: `Moved node ${id} to ${to}` };
return { message: `Moved node ${nodeId} to ${to}` };
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${id} to ${to}` },
{ message: `Failed to move node ${nodeId} to ${to}` },
{
status: 500,
},
@ -136,7 +158,7 @@ export async function menuAction({
case 'tags': {
const tags =
data
formData
.get('tags')
?.toString()
.split(',')
@ -144,7 +166,7 @@ export async function menuAction({
try {
await context.client.post(
`v1/node/${id}/tags`,
`v1/node/${nodeId}/tags`,
session.get('api_key')!,
{
tags,
@ -164,8 +186,8 @@ export async function menuAction({
}
case 'register': {
const key = data.get('mkey')?.toString();
const user = data.get('user')?.toString();
const key = formData.get('mkey')?.toString();
const user = formData.get('user')?.toString();
if (!key) {
return send(

View File

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

View File

@ -6,17 +6,40 @@ import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import { menuAction } from './action';
import MachineRow from './components/machine';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
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([
context.client.get<{ nodes: Machine[] }>(
'v1/node',
@ -45,11 +68,13 @@ export async function loader({
publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
writable: writablePermission,
subject: user.subject,
};
}
export async function action(request: ActionFunctionArgs) {
return menuAction(request);
return machineAction(request);
}
export default function Page() {
@ -73,6 +98,7 @@ export default function Page() {
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
isDisabled={!data.writable}
/>
</div>
<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
isAgent={data.agents?.includes(machine.id)}
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>

View File

@ -7,3 +7,30 @@ export function send<T>(payload: T, init?: number | ResponseInit) {
export function send401<T>(payload: T) {
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 },
);
}