feat: support oidc restriction management in the settings

This commit is contained in:
Aarnav Tale 2025-04-05 18:49:55 -04:00
parent faa61b0f1d
commit 5ae6e60db9
No known key found for this signature in database
10 changed files with 638 additions and 7 deletions

View File

@ -1,5 +1,12 @@
### Next
> Changes here are not considered stable and are only in pre-releases.
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
### 0.5.10 (April 4, 2025)
- Fix an issue where other prefernences to skip onboarding affected every user.
- Fix an issue where other preferences to skip onboarding affected every user.
### 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.

View File

@ -29,6 +29,7 @@ export default [
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
route('/restrictions', 'routes/settings/pages/restrictions.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),

View File

@ -0,0 +1,221 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
export async function restrictionAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.configure_iam,
);
if (!check) {
throw data('You do not have permission to modify IAM settings.', {
status: 403,
});
}
if (!context.hs.writable()) {
throw data('The Headscale configuration file is not editable.', {
status: 403,
});
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('No action provided.', {
status: 400,
});
}
switch (action) {
case 'add_domain': {
return addDomain(formData, context);
}
case 'remove_domain': {
return removeDomain(formData, context);
}
case 'add_group': {
return addGroup(formData, context);
}
case 'remove_group': {
return removeGroup(formData, context);
}
case 'add_user': {
return addUser(formData, context);
}
case 'remove_user': {
return removeUser(formData, context);
}
default: {
throw data('Invalid action provided.', {
status: 400,
});
}
}
}
async function addDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain added successfully.');
}
async function removeDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain removed successfully.');
}
async function addUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User added successfully.');
}
async function removeUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User removed successfully.');
}
async function addGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group added successfully.');
}
async function removeGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group removed successfully.');
}

View File

@ -0,0 +1,85 @@
import { GlobeLock, Group, User2 } from 'lucide-react';
import React from 'react';
import { Form } from 'react-router';
import Button from '~/components/Button';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
interface RestrictionProps {
children: React.ReactNode;
type: 'domain' | 'group' | 'user';
values: string[];
isDisabled?: boolean;
}
export default function Restriction({
children,
type,
values,
isDisabled,
}: RestrictionProps) {
return (
<div className="w-2/3">
<h2 className="text-2xl font-medium mt-8">
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
</h2>
<TableList className="my-4">
{values.length > 0 ? (
values.map((value) => (
<TableList.Item key={`${type}-${value}`}>
{type === 'domain' ? (
<p>
<span className="text-headplane-600 dark:text-headplane-300">
{'<user>'}
</span>
<span className="font-bold">@</span>
<span>{value}</span>
</p>
) : (
<p>{value}</p>
)}
<Form method="POST">
<input
type="hidden"
name="action_id"
value={`remove_${type}`}
/>
<input type="hidden" name={type} value={value} />
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
) : (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
{iconForType(type)}
<p className="font-semibold">
All {type}s are permitted to authenticate.
</p>
</TableList.Item>
)}
</TableList>
{children}
</div>
);
}
function iconForType(type: 'domain' | 'group' | 'user') {
if (type === 'domain') {
return <GlobeLock />;
}
if (type === 'group') {
return <Group />;
}
return <User2 />;
}

View File

@ -0,0 +1,64 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface AddDomainProps {
domains: string[];
isDisabled?: boolean;
}
export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
const [domain, setDomain] = useState('');
const isInvalid = useMemo(() => {
if (!domain || domain.trim().length === 0) {
// Empty domain is invalid, but no error shown
return false;
}
if (domains.includes(domain.trim())) {
return true;
}
try {
// Check if domain is a valid FQDN
const url = new URL(`http://${domain.trim()}`);
return url.hostname !== domain.trim();
} catch (e) {
// If URL constructor fails, it's not a valid domain
return true;
}
}, [domain, domains]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add domain</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add domain</Dialog.Title>
<Dialog.Text className="mb-4">
Add this domain to a list of allowed email domains that can
authenticate with Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_domain" />
<Input
isRequired
label="Domain"
description={
domain.trim().length > 0
? `Matches users with <user>@${domain.trim()}`
: 'Enter a domain to match users with their email addresses.'
}
placeholder="example.com"
name="domain"
onChange={setDomain}
isInvalid={domain.trim().length === 0 || isInvalid}
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The domain you entered is invalid or already exists in the list.
</p>
)}
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,51 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface AddGroupProps {
groups: string[];
isDisabled?: boolean;
}
export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
const [group, setGroup] = useState('');
const isInvalid = useMemo(() => {
if (!group || group.trim().length === 0) {
// Empty group is invalid, but no error shown
return false;
}
if (groups.includes(group.trim())) {
return true;
}
}, [group, groups]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add group</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add group</Dialog.Title>
<Dialog.Text className="mb-4">
Add this group to a list of allowed groups that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_group" />
<Input
isRequired
label="Group"
description="The group to allow for OIDC authentication."
placeholder="admin"
name="group"
onChange={setGroup}
isInvalid={group.trim().length === 0 || isInvalid}
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The group you entered already exists in the list of allowed groups.
</p>
)}
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,51 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface AddUserProps {
users: string[];
isDisabled?: boolean;
}
export default function AddUser({ users, isDisabled }: AddUserProps) {
const [user, setUser] = useState('');
const isInvalid = useMemo(() => {
if (!user || user.trim().length === 0) {
// Empty user is invalid, but no error shown
return false;
}
if (users.includes(user.trim())) {
return true;
}
}, [user, users]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add user</Dialog.Title>
<Dialog.Text className="mb-4">
Add this user to a list of allowed users that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_user" />
<Input
isRequired
label="User"
description="The user to allow for OIDC authentication."
placeholder="john_doe"
name="user"
onChange={setUser}
isInvalid={user.trim().length === 0 || isInvalid}
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The user you entered already exists in the list of allowed users.
</p>
)}
</Dialog.Panel>
</Dialog>
);
}

View File

@ -1,12 +1,22 @@
import { ArrowRightIcon } from '@primer/octicons-react';
import { Link as RemixLink } from 'react-router';
import Button from '~/components/Button';
import {
LoaderFunctionArgs,
Link as RemixLink,
useLoaderData,
} from 'react-router';
import Link from '~/components/Link';
import cn from '~/utils/cn';
import { LoadContext } from '~/server';
import AgentSection from './components/agent';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
return {
config: context.hs.writable(),
oidc: context.oidc,
};
}
export default function Page() {
const { config, oidc } = useLoaderData<typeof loader>();
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className="flex flex-col w-2/3">
@ -37,7 +47,34 @@ export default function Page() {
<ArrowRightIcon className="w-5 h-5 ml-2" />
</div>
</RemixLink>
{/**<AgentSection />**/}
{config && oidc ? (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">
Authentication Restrictions
</h1>
<p>
Headscale supports restricting OIDC authentication to only allow
certain email domains, groups, or users to authenticate. This can
be used to limit access to your Tailnet to only certain users or
groups and Headplane will also respect these settings when
authenticating.{' '}
<Link
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
name="Headscale OIDC documentation"
>
Learn More
</Link>
</p>
</div>
<RemixLink to="/settings/restrictions">
<div className="text-lg font-medium flex items-center">
Manage Restrictions
<ArrowRightIcon className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
}

View File

@ -0,0 +1,114 @@
import {
ActionFunctionArgs,
LoaderFunctionArgs,
Link as RemixLink,
data,
useLoaderData,
} from 'react-router';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { restrictionAction } from '../actions/restriction';
import Restriction from '../components/restriction';
import AddDomain from '../dialogs/add-domain';
import AddGroup from '../dialogs/add-group';
import AddUser from '../dialogs/add-user';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
throw data('You do not have permission to view IAM settings.', {
status: 403,
});
}
if (!context.hs.c?.oidc) {
throw data('OIDC is not configured on this Headscale instance.', {
status: 501,
});
}
return {
access: await context.sessions.check(request, Capabilities.configure_iam),
writable: context.hs.writable(),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
},
};
}
export async function action(request: ActionFunctionArgs) {
return restrictionAction(request);
}
export default function Page() {
const { access, writable, settings } = useLoaderData<typeof loader>();
const isDisabled = writable ? !access : true;
return (
<div className="flex flex-col gap-4 max-w-screen-lg">
<div className="flex flex-col w-2/3">
<p className="mb-4 text-md">
<RemixLink to="/settings" className="font-medium">
Settings
</RemixLink>
<span className="mx-2">/</span> Authentication Restrictions
</p>
{!access ? (
<Notice
title="Authentication permissions restricted"
variant="warning"
>
You do not have the necessary permissions to edit the Authentication
Restrictions settings. Please contact your administrator to request
access or to make changes to these settings.
</Notice>
) : !writable ? (
<Notice title="Configuration Locked" variant="error">
The Headscale configuration file is not editable through the web
interface. Please ensure that you have correctly given Headplane
write access to the file.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-2 mt-4">
Authentication Restrictions
</h1>
<p>
Headscale supports restricting OIDC authentication to only allow
certain email domains, groups, or users to authenticate. This can be
used to limit access to your Tailnet to only certain users or groups
and Headplane will also respect these settings when authenticating.{' '}
<Link
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
name="Headscale OIDC documentation"
>
Learn More
</Link>
</p>
</div>
<Restriction
type="domain"
values={settings.domains}
isDisabled={isDisabled}
>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</Restriction>
<Restriction
type="group"
values={settings.groups}
isDisabled={isDisabled}
>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</Restriction>
<Restriction type="user" values={settings.users} isDisabled={isDisabled}>
<AddUser users={settings.users} isDisabled={isDisabled} />
</Restriction>
</div>
);
}

View File

@ -22,7 +22,7 @@ export const Capabilities = {
// Write feature configuration, for example, enable Taildrop (unimplemented)
write_feature: 1 << 6,
// Configure user & group provisioning (unimplemented)
// Configure user & group provisioning
configure_iam: 1 << 7,
// Read machines, for example, see machine names and status