From 5ae6e60db9252035ea8da0e965290c19b8bd6a7d Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 5 Apr 2025 18:49:55 -0400 Subject: [PATCH] feat: support oidc restriction management in the settings --- CHANGELOG.md | 9 +- app/routes.ts | 1 + app/routes/settings/actions/restriction.ts | 221 ++++++++++++++++++ .../settings/components/restriction.tsx | 85 +++++++ app/routes/settings/dialogs/add-domain.tsx | 64 +++++ app/routes/settings/dialogs/add-group.tsx | 51 ++++ app/routes/settings/dialogs/add-user.tsx | 51 ++++ app/routes/settings/overview.tsx | 47 +++- app/routes/settings/pages/restrictions.tsx | 114 +++++++++ app/server/web/roles.ts | 2 +- 10 files changed, 638 insertions(+), 7 deletions(-) create mode 100644 app/routes/settings/actions/restriction.ts create mode 100644 app/routes/settings/components/restriction.tsx create mode 100644 app/routes/settings/dialogs/add-domain.tsx create mode 100644 app/routes/settings/dialogs/add-group.tsx create mode 100644 app/routes/settings/dialogs/add-user.tsx create mode 100644 app/routes/settings/pages/restrictions.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ac135..3a28bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/routes.ts b/app/routes.ts index 1a0adb4..cd45e65 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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'), ]), ]), diff --git a/app/routes/settings/actions/restriction.ts b/app/routes/settings/actions/restriction.ts new file mode 100644 index 0000000..e64ef41 --- /dev/null +++ b/app/routes/settings/actions/restriction.ts @@ -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) { + 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.'); +} diff --git a/app/routes/settings/components/restriction.tsx b/app/routes/settings/components/restriction.tsx new file mode 100644 index 0000000..fb196ce --- /dev/null +++ b/app/routes/settings/components/restriction.tsx @@ -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 ( +
+

+ Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s +

+ + {values.length > 0 ? ( + values.map((value) => ( + + {type === 'domain' ? ( +

+ + {''} + + @ + {value} +

+ ) : ( +

{value}

+ )} +
+ + + +
+
+ )) + ) : ( + + {iconForType(type)} +

+ All {type}s are permitted to authenticate. +

+
+ )} +
+ {children} +
+ ); +} + +function iconForType(type: 'domain' | 'group' | 'user') { + if (type === 'domain') { + return ; + } + + if (type === 'group') { + return ; + } + + return ; +} diff --git a/app/routes/settings/dialogs/add-domain.tsx b/app/routes/settings/dialogs/add-domain.tsx new file mode 100644 index 0000000..174aaf5 --- /dev/null +++ b/app/routes/settings/dialogs/add-domain.tsx @@ -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 ( + + Add domain + + Add domain + + Add this domain to a list of allowed email domains that can + authenticate with Headscale via OIDC. + + + 0 + ? `Matches users with @${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 && ( +

+ The domain you entered is invalid or already exists in the list. +

+ )} +
+
+ ); +} diff --git a/app/routes/settings/dialogs/add-group.tsx b/app/routes/settings/dialogs/add-group.tsx new file mode 100644 index 0000000..c308a20 --- /dev/null +++ b/app/routes/settings/dialogs/add-group.tsx @@ -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 ( + + Add group + + Add group + + Add this group to a list of allowed groups that can authenticate with + Headscale via OIDC. + + + + {isInvalid && ( +

+ The group you entered already exists in the list of allowed groups. +

+ )} +
+
+ ); +} diff --git a/app/routes/settings/dialogs/add-user.tsx b/app/routes/settings/dialogs/add-user.tsx new file mode 100644 index 0000000..a9c20d3 --- /dev/null +++ b/app/routes/settings/dialogs/add-user.tsx @@ -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 ( + + Add user + + Add user + + Add this user to a list of allowed users that can authenticate with + Headscale via OIDC. + + + + {isInvalid && ( +

+ The user you entered already exists in the list of allowed users. +

+ )} +
+
+ ); +} diff --git a/app/routes/settings/overview.tsx b/app/routes/settings/overview.tsx index 04529a5..c86788d 100644 --- a/app/routes/settings/overview.tsx +++ b/app/routes/settings/overview.tsx @@ -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) { + return { + config: context.hs.writable(), + oidc: context.oidc, + }; +} export default function Page() { + const { config, oidc } = useLoaderData(); + return (
@@ -37,7 +47,34 @@ export default function Page() {
- {/****/} + {config && oidc ? ( + <> +
+

+ Authentication Restrictions +

+

+ 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.{' '} + + Learn More + +

+
+ +
+ Manage Restrictions + +
+
+ + ) : undefined}
); } diff --git a/app/routes/settings/pages/restrictions.tsx b/app/routes/settings/pages/restrictions.tsx new file mode 100644 index 0000000..b6837d7 --- /dev/null +++ b/app/routes/settings/pages/restrictions.tsx @@ -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) { + 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(); + const isDisabled = writable ? !access : true; + + return ( +
+
+

+ + Settings + + / Authentication Restrictions +

+ {!access ? ( + + 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. + + ) : !writable ? ( + + The Headscale configuration file is not editable through the web + interface. Please ensure that you have correctly given Headplane + write access to the file. + + ) : undefined} +

+ Authentication Restrictions +

+

+ 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.{' '} + + Learn More + +

+
+ + + + + + + + + +
+ ); +} diff --git a/app/server/web/roles.ts b/app/server/web/roles.ts index 2569cb2..786aafc 100644 --- a/app/server/web/roles.ts +++ b/app/server/web/roles.ts @@ -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