feat: add capabilities enforcement on users

This commit is contained in:
Aarnav Tale 2025-04-02 23:34:32 -04:00
parent 5d3fada266
commit 259d150fc4
4 changed files with 31 additions and 21 deletions

View File

@ -4,11 +4,12 @@ import Link from '~/components/Link';
import type { HeadplaneConfig } from '~/server/config/schema'; import type { HeadplaneConfig } from '~/server/config/schema';
import CreateUser from '../dialogs/create-user'; import CreateUser from '../dialogs/create-user';
interface Props { interface ManageBannerProps {
oidc?: NonNullable<HeadplaneConfig['oidc']>; oidc?: NonNullable<HeadplaneConfig['oidc']>;
isDisabled?: boolean;
} }
export default function ManageBanner({ oidc }: Props) { export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return ( return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0"> <Card variant="flat" className="mb-8 w-full max-w-full p-0">
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
@ -60,7 +61,7 @@ export default function ManageBanner({ oidc }: Props) {
: 'You can add, remove, and rename users here.'} : 'You can add, remove, and rename users here.'}
</p> </p>
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
<CreateUser /> <CreateUser isDisabled={isDisabled} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,11 +1,15 @@
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Input from '~/components/Input'; import Input from '~/components/Input';
interface CreateUserProps {
isDisabled?: boolean;
}
// TODO: Support image upload for user avatars // TODO: Support image upload for user avatars
export default function CreateUser() { export default function CreateUser({ isDisabled }: CreateUserProps) {
return ( return (
<Dialog> <Dialog>
<Dialog.Button>Add a new user</Dialog.Button> <Dialog.Button isDisabled={isDisabled}>Add a new user</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
<Dialog.Title>Add a new user</Dialog.Title> <Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-6"> <Dialog.Text className="mb-6">

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useSubmit } from 'react-router'; import { useLoaderData, useSubmit } from 'react-router';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types'; import { Machine, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import ManageBanner from './components/manage-banner'; import ManageBanner from './components/manage-banner';
@ -17,6 +18,19 @@ export async function loader({
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users);
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_users,
);
const [machines, apiUsers] = await Promise.all([ const [machines, apiUsers] = await Promise.all([
context.client.get<{ nodes: Machine[] }>( context.client.get<{ nodes: Machine[] }>(
'v1/node', 'v1/node',
@ -63,6 +77,7 @@ export async function loader({
} }
return { return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc, oidc: context.config.oidc,
roles, roles,
magic, magic,
@ -93,7 +108,7 @@ export default function Page() {
Manage the users in your network and their permissions. Tip: You can Manage the users in your network and their permissions. Tip: You can
drag machines between users to change ownership. drag machines between users to change ownership.
</p> </p>
<ManageBanner oidc={data.oidc} /> <ManageBanner oidc={data.oidc} isDisabled={!data.writable} />
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300"> <thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5"> <tr className="text-left px-0.5">

View File

@ -9,8 +9,12 @@ export async function userAction({
context, context,
}: ActionFunctionArgs<LoadContext>) { }: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const apiKey = session.get('api_key')!; const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) {
return data({ success: false }, 403);
}
const apiKey = session.get('api_key')!;
const formData = await request.formData(); const formData = await request.formData();
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
@ -84,20 +88,6 @@ async function reassignUser(
context: LoadContext, context: LoadContext,
session: Session<AuthSession, unknown>, session: Session<AuthSession, unknown>,
) { ) {
const executor = session.get('user');
if (!executor?.subject) {
return data({ success: false }, 400);
}
const check = await context.sessions.checkSubject(
executor.subject,
Capabilities.write_users,
);
if (!check) {
return data({ success: false }, 403);
}
const userId = formData.get('user_id')?.toString(); const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString(); const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) { if (!userId || !newRole) {