feat: add permissions check on dns page

This commit is contained in:
Aarnav Tale 2025-04-02 23:29:28 -04:00
parent 9d046a0cf6
commit 5d3fada266
5 changed files with 102 additions and 21 deletions

View File

@ -15,9 +15,16 @@ import cn from '~/utils/cn';
interface Props { interface Props {
configAvailable: boolean; configAvailable: boolean;
uiAccess: boolean;
onboarding: boolean; onboarding: boolean;
user?: AuthSession['user']; user?: AuthSession['user'];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
} }
interface LinkProps { interface LinkProps {
@ -137,27 +144,45 @@ export default function Header(data: Props) {
) : undefined} ) : undefined}
</div> </div>
</div> </div>
{data.uiAccess && !data.onboarding ? ( {data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold"> <nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
<TabLink {data.access.machines ? (
to="/machines" <TabLink
name="Machines" to="/machines"
icon={<Server className="w-5" />} name="Machines"
/> icon={<Server className="w-5" />}
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} /> />
<TabLink ) : undefined}
to="/acls" {data.access.users ? (
name="Access Control" <TabLink
icon={<Lock className="w-5" />} to="/users"
/> name="Users"
icon={<Users className="w-5" />}
/>
) : undefined}
{data.access.policy ? (
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
) : undefined}
{data.configAvailable ? ( {data.configAvailable ? (
<> <>
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} /> {data.access.dns ? (
<TabLink <TabLink
to="/settings" to="/dns"
name="Settings" name="DNS"
icon={<Settings className="w-5" />} icon={<Globe2 className="w-5" />}
/> />
) : undefined}
{data.access.settings ? (
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
) : undefined}
</> </>
) : undefined} ) : undefined}
</nav> </nav>

View File

@ -1,6 +1,7 @@
import { XCircleFillIcon } from '@primer/octicons-react'; import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router'; import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router'; import { Outlet, useLoaderData } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { ResponseError } from '~/server/headscale/api-client'; import { ResponseError } from '~/server/headscale/api-client';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
@ -65,3 +66,7 @@ export default function Layout() {
</> </>
); );
} }
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}

View File

@ -93,6 +93,20 @@ export async function loader({
debug: context.config.debug, debug: context.config.debug,
user: session.get('user'), user: session.get('user'),
uiAccess: check, uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'), onboarding: request.url.endsWith('/onboarding'),
}; };
} catch { } catch {

View File

@ -1,10 +1,20 @@
import { ActionFunctionArgs, data } from 'react-router'; import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server'; import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
export async function dnsAction({ export async function dnsAction({
request, request,
context, context,
}: ActionFunctionArgs<LoadContext>) { }: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
);
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) { if (!context.hs.writable()) {
return data({ success: false }, 403); return data({ success: false }, 403);
} }

View File

@ -3,6 +3,7 @@ import { useLoaderData } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Notice from '~/components/Notice'; import Notice from '~/components/Notice';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import ManageDomains from './components/manage-domains'; import ManageDomains from './components/manage-domains';
import ManageNS from './components/manage-ns'; import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records'; import ManageRecords from './components/manage-records';
@ -11,11 +12,30 @@ import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions'; import { dnsAction } from './dns-actions';
// We do not want to expose every config value // We do not want to expose every config value
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) { export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.hs.readable()) { if (!context.hs.readable()) {
throw new Error('No configuration is available'); throw new Error('No configuration is available');
} }
const check = await context.sessions.check(
request,
Capabilities.read_network,
);
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_network,
);
const config = context.hs.c!; const config = context.hs.c!;
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
@ -29,6 +49,7 @@ export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
return { return {
...dns, ...dns,
access: writablePermission,
writable: context.hs.writable(), writable: context.hs.writable(),
}; };
} }
@ -46,7 +67,7 @@ export default function Page() {
} }
allNs.global = data.nameservers; allNs.global = data.nameservers;
const isDisabled = data.writable === false; const isDisabled = data.access === false || data.writable === false;
return ( return (
<div className="flex flex-col gap-16 max-w-screen-lg"> <div className="flex flex-col gap-16 max-w-screen-lg">
@ -56,6 +77,12 @@ export default function Page() {
the configuration the configuration
</Notice> </Notice>
)} )}
{data.access ? undefined : (
<Notice>
Your permissions do not allow you to modify the DNS settings for this
tailnet.
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} /> <RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} /> <ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} /> <ManageRecords records={data.extraRecords} isDisabled={isDisabled} />