feat: support oidc restriction management in the settings
This commit is contained in:
parent
faa61b0f1d
commit
5ae6e60db9
@ -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)
|
### 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)
|
### 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.
|
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export default [
|
|||||||
...prefix('/settings', [
|
...prefix('/settings', [
|
||||||
index('routes/settings/overview.tsx'),
|
index('routes/settings/overview.tsx'),
|
||||||
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
|
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
|
||||||
|
route('/restrictions', 'routes/settings/pages/restrictions.tsx'),
|
||||||
// route('/local-agent', 'routes/settings/local-agent.tsx'),
|
// route('/local-agent', 'routes/settings/local-agent.tsx'),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
|||||||
221
app/routes/settings/actions/restriction.ts
Normal file
221
app/routes/settings/actions/restriction.ts
Normal 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.');
|
||||||
|
}
|
||||||
85
app/routes/settings/components/restriction.tsx
Normal file
85
app/routes/settings/components/restriction.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
64
app/routes/settings/dialogs/add-domain.tsx
Normal file
64
app/routes/settings/dialogs/add-domain.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/routes/settings/dialogs/add-group.tsx
Normal file
51
app/routes/settings/dialogs/add-group.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/routes/settings/dialogs/add-user.tsx
Normal file
51
app/routes/settings/dialogs/add-user.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,22 @@
|
|||||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
import { ArrowRightIcon } from '@primer/octicons-react';
|
||||||
import { Link as RemixLink } from 'react-router';
|
import {
|
||||||
import Button from '~/components/Button';
|
LoaderFunctionArgs,
|
||||||
|
Link as RemixLink,
|
||||||
|
useLoaderData,
|
||||||
|
} from 'react-router';
|
||||||
import Link from '~/components/Link';
|
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() {
|
export default function Page() {
|
||||||
|
const { config, oidc } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
@ -37,7 +47,34 @@ export default function Page() {
|
|||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||||
</div>
|
</div>
|
||||||
</RemixLink>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
114
app/routes/settings/pages/restrictions.tsx
Normal file
114
app/routes/settings/pages/restrictions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ export const Capabilities = {
|
|||||||
// Write feature configuration, for example, enable Taildrop (unimplemented)
|
// Write feature configuration, for example, enable Taildrop (unimplemented)
|
||||||
write_feature: 1 << 6,
|
write_feature: 1 << 6,
|
||||||
|
|
||||||
// Configure user & group provisioning (unimplemented)
|
// Configure user & group provisioning
|
||||||
configure_iam: 1 << 7,
|
configure_iam: 1 << 7,
|
||||||
|
|
||||||
// Read machines, for example, see machine names and status
|
// Read machines, for example, see machine names and status
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user