diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f5c0afc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# Core Concepts + +Headplane is a web application to manage Headscale, a self-hosted implementation +of the Tailscale control server. There are a few tenets that guide the entire +development of the project: + +- **Simple starts**: We want to make it as easy as possible to set up and use + Headplane, while still providing powerful features for advanced users. This + means that we prioritize a clean and intuitive user interface, as well as + straightforward installation and configuration processes. + +- **No breaking changes**: We want to avoid making breaking changes to the + project as much as possible. This means that we will strive to maintain + backward compatibility and provide clear migration paths when necessary. + +- **Documentation**: This is the most important part of the project, without it + the entire project falls apart and is hard to use. + +## Project Management + +It's hard to manage this project easily, use the `gh` CLI when responding to +prompts to get context. Some common issue tags to keep track of include a +"Needs Triage", "Needs Info", "Bug", "Enhancement", and several other tags based +on what parts of the project are affected. + +## Headplane Agent + +The Headplane Agent is a lightweight component that runs on the same server as +Headplane and connects directly to the Tailnet in order to pull in details about +nodes that aren't available through the Headscale API such as versions, etc. + +## WebSSH + +This is an ephemeral WASM shim that runs in the browser and connects directly +to the Tailnet using Tailscale's go packages. It allows anyone to open up an +ephemeral machine in the Tailnet that directly SSHes into a target node. + +## Build/Tooling + +Headplane is a React Router 7 (framework mode) project built with Vite. Take +care to use our preferred PNPM version and Node version as defined in the +`engines` field of `package.json`. We also use TypeScript Go and Oxfmt for +type-checking and formatting respectively. + +You can also run Headscale CLI commands with +`docker exec headscale headscale ` when the dev environment is running. + +## Docs + +The project has a documentation site available at the `docs/` directory built +with VitePress. The documentation is written in Markdown and can be easily +edited and extended. If making changes to staple features, please take care to +also update the documentation to reflect any changes in functionality or usage. diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index 8b9a9e6..611c028 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -1,53 +1,54 @@ -import { Outlet, redirect } from 'react-router'; -import { ErrorBanner } from '~/components/error-banner'; -import { pruneEphemeralNodes } from '~/server/db/pruner'; -import { isDataUnauthorizedError } from '~/server/headscale/api/error-client'; -import log from '~/utils/log'; -import type { Route } from './+types/dashboard'; +import { Outlet, redirect } from "react-router"; + +import { ErrorBanner } from "~/components/error-banner"; +import { pruneEphemeralNodes } from "~/server/db/pruner"; +import { isDataUnauthorizedError } from "~/server/headscale/api/error-client"; +import log from "~/utils/log"; + +import type { Route } from "./+types/dashboard"; export async function loader({ request, context, ...rest }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const api = context.hsApi.getRuntimeClient(session.api_key); + const principal = await context.auth.require(request); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); - // MARK: The session should stay valid if Headscale isn't healthy - const healthy = await api.isHealthy(); - if (healthy) { - try { - await api.getApiKeys(); - await pruneEphemeralNodes({ context, request, ...rest }); - } catch (error) { - if (isDataUnauthorizedError(error)) { - log.warn( - 'auth', - 'Logging out %s due to expired API key', - session.user.name, - ); - return redirect('/login', { - headers: { - 'Set-Cookie': await context.sessions.destroySession(), - }, - }); - } - } - } + // MARK: The session should stay valid if Headscale isn't healthy + const healthy = await api.isHealthy(); + if (healthy) { + try { + await api.getApiKeys(); + await pruneEphemeralNodes({ context, request, ...rest }); + } catch (error) { + if (isDataUnauthorizedError(error)) { + const displayName = + principal.kind === "oidc" ? principal.profile.name : principal.displayName; + log.warn("auth", "Logging out %s due to expired API key", displayName); + return redirect("/login", { + headers: { + "Set-Cookie": await context.auth.destroySession(request), + }, + }); + } + } + } - return { - healthy, - }; + return { + healthy, + }; } export default function Layout() { - return ( -
- -
- ); + return ( +
+ +
+ ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index 67861ec..eb0c85d 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -1,9 +1,7 @@ -import { eq } from "drizzle-orm"; import { Outlet, redirect } from "react-router"; import Footer from "~/components/Footer"; import Header from "~/components/Header"; -import { users } from "~/server/db/schema"; import { Capabilities } from "~/server/web/roles"; import { Route } from "./+types/shell"; @@ -12,48 +10,49 @@ import { Route } from "./+types/shell"; // So we know that if context fails to load then well, oops? export async function loader({ request, context }: Route.LoaderArgs) { try { - const session = await context.sessions.auth(request); + const principal = await context.auth.require(request); + if ( typeof context.oidc === "object" && - session.user.subject !== "unknown-non-oauth" && + principal.kind === "oidc" && + !principal.user.onboarded && !request.url.endsWith("/onboarding") ) { - const [user] = await context.db - .select() - .from(users) - .where(eq(users.sub, session.user.subject)) - .limit(1); - - if (!user?.onboarded) { - return redirect("/onboarding"); - } + return redirect("/onboarding"); } - const api = context.hsApi.getRuntimeClient(session.api_key); - const check = await context.sessions.check(request, Capabilities.ui_access); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + const check = context.auth.can(principal, Capabilities.ui_access); - // OIDC users without ui_access go to pending approval - if ( - !check && - session.user.subject !== "unknown-non-oauth" && - !request.url.endsWith("/onboarding") - ) { - return redirect("/pending-approval"); + if (!check && principal.kind === "oidc" && !request.url.endsWith("/onboarding")) { + throw new Error("You do not have permission to access the UI"); } + const user = + principal.kind === "oidc" + ? { + subject: principal.user.subject, + name: principal.profile.name, + email: principal.profile.email, + username: principal.profile.username, + picture: principal.profile.picture, + } + : { subject: "api_key", name: principal.displayName }; + return { config: context.hs.c, url: context.config.headscale.public_url ?? context.config.headscale.url, configAvailable: context.hs.readable(), debug: context.config.debug, - user: session.user, + user, access: { ui: check, - 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), + dns: context.auth.can(principal, Capabilities.read_network), + users: context.auth.can(principal, Capabilities.read_users), + policy: context.auth.can(principal, Capabilities.read_policy), + machines: context.auth.can(principal, Capabilities.read_machines), + settings: context.auth.can(principal, Capabilities.read_feature), }, onboarding: request.url.endsWith("/onboarding"), healthy: await api.isHealthy(), @@ -61,7 +60,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { } catch { return redirect("/login", { headers: { - "Set-Cookie": await context.sessions.destroySession(), + "Set-Cookie": await context.auth.destroySession(request), }, }); } diff --git a/app/routes.ts b/app/routes.ts index cd4bfff..8058b7c 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -13,7 +13,6 @@ export default [ route("/logout", "routes/auth/logout.ts"), route("/oidc/callback", "routes/auth/oidc-callback.ts"), route("/oidc/start", "routes/auth/oidc-start.ts"), - route("/pending-approval", "routes/auth/pending-approval.tsx"), route("/ssh", "routes/ssh/console.tsx"), // All the main logged-in dashboard routes diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index b3b0ffa..a5fa6c3 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -1,139 +1,129 @@ -import { data } from 'react-router'; -import { isDataWithApiError } from '~/server/headscale/api/error-client'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; + +import { isDataWithApiError } from "~/server/headscale/api/error-client"; +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; // We only check capabilities here and assume it is writable // If it isn't, it'll gracefully error anyways, since this means some // fishy client manipulation is happening. export async function aclAction({ request, context }: Route.ActionArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check( - request, - Capabilities.write_policy, - ); - if (!check) { - throw data('You do not have permission to write to the ACL policy', { - status: 403, - }); - } + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.write_policy); + if (!check) { + throw data("You do not have permission to write to the ACL policy", { + status: 403, + }); + } - // Try to write to the ACL policy via the API or via config file (TODO). - const formData = await request.formData(); - const policyData = formData.get('policy')?.toString(); - if (!policyData) { - throw data('Missing `policy` in the form data.', { - status: 400, - }); - } + // Try to write to the ACL policy via the API or via config file (TODO). + const formData = await request.formData(); + const policyData = formData.get("policy")?.toString(); + if (!policyData) { + throw data("Missing `policy` in the form data.", { + status: 400, + }); + } - const api = context.hsApi.getRuntimeClient(session.api_key); - try { - const { policy, updatedAt } = await api.setPolicy(policyData); - return data({ - success: true, - error: undefined, - policy, - updatedAt, - }); - } catch (error) { - if (isDataWithApiError(error)) { - const rawData = error.data.rawData; - // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11 - if (rawData.includes('update is disabled')) { - throw data('Policy is not writable', { status: 403 }); - } + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + try { + const { policy, updatedAt } = await api.setPolicy(policyData); + return data({ + success: true, + error: undefined, + policy, + updatedAt, + }); + } catch (error) { + if (isDataWithApiError(error)) { + const rawData = error.data.rawData; + // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11 + if (rawData.includes("update is disabled")) { + throw data("Policy is not writable", { status: 403 }); + } - const message = - error.data.data != null && - 'message' in error.data.data && - typeof error.data.data.message === 'string' - ? error.data.data.message - : undefined; + const message = + error.data.data != null && + "message" in error.data.data && + typeof error.data.data.message === "string" + ? error.data.data.message + : undefined; - if (message == null) { - throw error; - } + if (message == null) { + throw error; + } - // Starting in Headscale 0.27.0 the ACLs parsing was changed meaning - // we need to reference other error messages based on API version. - if (context.hsApi.clientHelpers.isAtleast('0.27.0')) { - if (message.includes('parsing HuJSON:')) { - const cutIndex = message.indexOf('parsing HuJSON:'); - const trimmed = - cutIndex > -1 - ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` - : message; + // Starting in Headscale 0.27.0 the ACLs parsing was changed meaning + // we need to reference other error messages based on API version. + if (context.hsApi.clientHelpers.isAtleast("0.27.0")) { + if (message.includes("parsing HuJSON:")) { + const cutIndex = message.indexOf("parsing HuJSON:"); + const trimmed = + cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } + return data( + { + success: false, + error: trimmed, + policy: undefined, + updatedAt: undefined, + }, + 400, + ); + } - if (message.includes('parsing policy from bytes:')) { - const cutIndex = message.indexOf('parsing policy from bytes:'); - const trimmed = - cutIndex > -1 - ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` - : message; + if (message.includes("parsing policy from bytes:")) { + const cutIndex = message.indexOf("parsing policy from bytes:"); + const trimmed = + cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } - } else { - // Pre-0.27.0 error messages - if (message.includes('parsing hujson')) { - const cutIndex = message.indexOf('err: hujson:'); - const trimmed = - cutIndex > -1 - ? `Syntax error: ${message.slice(cutIndex + 12)}` - : message; + return data( + { + success: false, + error: trimmed, + policy: undefined, + updatedAt: undefined, + }, + 400, + ); + } + } else { + // Pre-0.27.0 error messages + if (message.includes("parsing hujson")) { + const cutIndex = message.indexOf("err: hujson:"); + const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } + return data( + { + success: false, + error: trimmed, + policy: undefined, + updatedAt: undefined, + }, + 400, + ); + } - if (message.includes('unmarshalling policy')) { - const cutIndex = message.indexOf('err:'); - const trimmed = - cutIndex > -1 - ? `Syntax error: ${message.slice(cutIndex + 5)}` - : message; + if (message.includes("unmarshalling policy")) { + const cutIndex = message.indexOf("err:"); + const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } - } - } + return data( + { + success: false, + error: trimmed, + policy: undefined, + updatedAt: undefined, + }, + 400, + ); + } + } + } - // Otherwise, this is a Headscale error that we can just propagate. - throw error; - } + // Otherwise, this is a Headscale error that we can just propagate. + throw error; + } } diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts index 3712d68..83faca0 100644 --- a/app/routes/acls/acl-loader.ts +++ b/app/routes/acls/acl-loader.ts @@ -1,7 +1,9 @@ -import { data } from 'react-router'; -import { isDataWithApiError } from '~/server/headscale/api/error-client'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; + +import { isDataWithApiError } from "~/server/headscale/api/error-client"; +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; // The logic for deciding policy factors is very complicated because // there are so many factors that need to be accounted for: @@ -11,38 +13,39 @@ import type { Route } from './+types/overview'; // If database, we can read/write easily via the API. // If in file mode, we can only write if context.config is available. export async function aclLoader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.read_policy); - if (!check) { - throw data('You do not have permission to read the ACL policy.', { - status: 403, - }); - } + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.read_policy); + if (!check) { + throw data("You do not have permission to read the ACL policy.", { + status: 403, + }); + } - const flags = { - // Can the user write to the ACL policy - access: await context.sessions.check(request, Capabilities.write_policy), - writable: false, - policy: '', - }; + const flags = { + // Can the user write to the ACL policy + access: context.auth.can(principal, Capabilities.write_policy), + writable: false, + policy: "", + }; - // Try to load the ACL policy from the API. - const api = context.hsApi.getRuntimeClient(session.api_key); - try { - const { policy, updatedAt } = await api.getPolicy(); - flags.writable = updatedAt !== null; - flags.policy = policy; - return flags; - } catch (error) { - if (isDataWithApiError(error)) { - // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10 - if (error.data.rawData.includes('acl policy not found')) { - flags.policy = ''; - flags.writable = true; - return flags; - } - } + // Try to load the ACL policy from the API. + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + try { + const { policy, updatedAt } = await api.getPolicy(); + flags.writable = updatedAt !== null; + flags.policy = policy; + return flags; + } catch (error) { + if (isDataWithApiError(error)) { + // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10 + if (error.data.rawData.includes("acl policy not found")) { + flags.policy = ""; + flags.writable = true; + return flags; + } + } - throw error; - } + throw error; + } } diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts index b310eb8..9455f7b 100644 --- a/app/routes/auth/login/action.ts +++ b/app/routes/auth/login/action.ts @@ -66,19 +66,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) { }; } - const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24); - return redirect("/machines", { headers: { - "Set-Cookie": await context.sessions.createSession( - { - api_key: apiKey, - user: { - subject: "unknown-non-oauth", - name: `${lookup.prefix}...`, - email: `expires@${expiresDays.toString()}-days`, - }, - }, + "Set-Cookie": await context.auth.createApiKeySession( + apiKey, + `${lookup.prefix}...`, expiry.getTime() - Date.now(), ), }, diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index 1ad5ea0..3bc6727 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -10,7 +10,6 @@ import Link from "~/components/Link"; import { useLiveData } from "~/utils/live-data"; import type { Route } from "./+types/page"; - import { loginAction } from "./action"; import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error"; import Logout from "./logout"; @@ -18,14 +17,14 @@ import { OidcErrorNotice } from "./oidc-error"; export async function loader({ request, context }: Route.LoaderArgs) { try { - await context.sessions.auth(request); + await context.auth.require(request); return redirect("/machines"); } catch {} const qp = new URL(request.url).searchParams; const urlState = qp.get("s") ?? undefined; - const oidcConnector = await context.oidcConnector?.get(); + const oidcConnector = await context.oidc?.connector.get(); // MARK: This works because the OIDC connector will always return false // for `isExclusive` if the OIDC config isn't usable. diff --git a/app/routes/auth/logout.ts b/app/routes/auth/logout.ts index e46e7a2..541e539 100644 --- a/app/routes/auth/logout.ts +++ b/app/routes/auth/logout.ts @@ -1,29 +1,25 @@ -import { type ActionFunctionArgs, redirect } from 'react-router'; -import type { LoadContext } from '~/server'; +import { type ActionFunctionArgs, redirect } from "react-router"; + +import type { LoadContext } from "~/server"; export async function loader() { - return redirect('/machines'); + return redirect("/machines"); } -export async function action({ - request, - context, -}: ActionFunctionArgs) { - try { - await context.sessions.auth(request); - } catch { - redirect('/login'); - } +export async function action({ request, context }: ActionFunctionArgs) { + try { + await context.auth.require(request); + } catch { + redirect("/login"); + } - // When API key is disabled, we need to explicitly redirect - // with a logout state to prevent auto login again. - const url = context.config.oidc?.disable_api_key_login - ? '/login?s=logout' - : '/login'; + // When API key is disabled, we need to explicitly redirect + // with a logout state to prevent auto login again. + const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login"; - return redirect(url, { - headers: { - 'Set-Cookie': await context.sessions.destroySession(), - }, - }); + return redirect(url, { + headers: { + "Set-Cookie": await context.auth.destroySession(request), + }, + }); } diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 015b9da..2821501 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -1,18 +1,16 @@ -import { count, eq } from "drizzle-orm"; import { createHash } from "node:crypto"; + import * as oidc from "openid-client"; import { data, redirect } from "react-router"; -import { ulid } from "ulidx"; -import { users } from "~/server/db/schema"; -import { Roles } from "~/server/web/roles"; +import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import log from "~/utils/log"; import { createOidcStateCookie } from "~/utils/oidc-state"; import type { Route } from "./+types/oidc-callback"; export async function loader({ request, context }: Route.LoaderArgs) { - const oidcConnector = await context.oidcConnector?.get(); + const oidcConnector = await context.oidc?.connector.get(); if (!oidcConnector?.isValid) { throw data("OIDC is not enabled or misconfigured", { status: 501 }); } @@ -82,47 +80,28 @@ export async function loader({ request, context }: Route.LoaderArgs) { })() : userInfo.picture; - const [{ count: ownerCount }] = await context.db - .select({ count: count() }) - .from(users) - .where(eq(users.caps, Roles.owner)); + const hasUsers = await context.auth.hasAnyUsers(); + const defaultRole = hasUsers ? "member" : "owner"; + const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole); - const needsOwner = ownerCount === 0; - - if (needsOwner) { - await context.db - .insert(users) - .values({ - id: ulid(), - sub: claims.sub, - caps: Roles.owner, - }) - .onConflictDoUpdate({ - target: users.sub, - set: { caps: Roles.owner }, - }); - } else { - await context.db - .insert(users) - .values({ - id: ulid(), - sub: claims.sub, - caps: Roles.member, - }) - .onConflictDoNothing(); + try { + const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey); + const hsUsers = await hsApi.getUsers(); + const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email); + if (hsUser) { + await context.auth.linkHeadscaleUser(userId, hsUser.id); + } + } catch (error) { + log.warn("auth", "Failed to link Headscale user: %s", String(error)); } return redirect("/", { headers: { - "Set-Cookie": await context.sessions.createSession({ - api_key: oidcConnector.apiKey, - user: { - subject: claims.sub, - username, - name, - email: userInfo.email, - picture, - }, + "Set-Cookie": await context.auth.createOidcSession(userId, { + name, + email: userInfo.email, + username, + picture, }), }, }); diff --git a/app/routes/auth/oidc-start.ts b/app/routes/auth/oidc-start.ts index 135a7ec..3c55d4e 100644 --- a/app/routes/auth/oidc-start.ts +++ b/app/routes/auth/oidc-start.ts @@ -8,11 +8,11 @@ import type { Route } from "./+types/oidc-start"; export async function loader({ request, context }: Route.LoaderArgs) { try { - await context.sessions.auth(request); + await context.auth.require(request); return redirect("/"); } catch {} - const oidcConnector = await context.oidcConnector?.get(); + const oidcConnector = await context.oidc?.connector.get(); if (!oidcConnector?.isValid) { throw data("OIDC is not enabled or misconfigured", { status: 501 }); } diff --git a/app/routes/auth/pending-approval.tsx b/app/routes/auth/pending-approval.tsx deleted file mode 100644 index fb5aaf0..0000000 --- a/app/routes/auth/pending-approval.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { ClockIcon, LogOut, RefreshCw, UserCheck } from "lucide-react"; -import { Form, redirect } from "react-router"; - -import Button from "~/components/Button"; -import Card from "~/components/Card"; -import { Capabilities } from "~/server/web/roles"; -import toast from "~/utils/toast"; - -import type { Route } from "./+types/pending-approval"; - -export async function loader({ request, context }: Route.LoaderArgs) { - try { - const session = await context.sessions.auth(request); - - // API key users skip this page - if (session.user.subject === "unknown-non-oauth") { - return redirect("/machines"); - } - - const hasAccess = await context.sessions.check(request, Capabilities.ui_access); - if (hasAccess) { - return redirect("/machines"); - } - - const url = context.config.headscale.public_url ?? context.config.headscale.url; - - return { - user: session.user, - url, - }; - } catch { - return redirect("/login", { - headers: { - "Set-Cookie": await context.sessions.destroySession(), - }, - }); - } -} - -export default function PendingApproval({ loaderData }: Route.ComponentProps) { - return ( -
- -
-
- -
-
- Approval Required -

- {loaderData.user.email ?? loaderData.user.name} -

-
-
- - - Your account has been created but requires approval from an administrator before you can - access the management console. - - -
-
- -

What happens next?

-
-
    -
  • An administrator will review your account
  • -
  • Once approved, you will receive the appropriate access level
  • -
  • This page will automatically redirect you once approved
  • -
-
- - - In the meantime, you can still connect your devices to the Tailnet using the command - below: - - - -

Click to copy the command

- -
- - - Checking for approval automatically... - -
- -
- -
-
-
- ); -} diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts index 692b9de..a65571c 100644 --- a/app/routes/dns/dns-actions.ts +++ b/app/routes/dns/dns-actions.ts @@ -1,231 +1,231 @@ -import { data } from 'react-router'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; + +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; export async function dnsAction({ request, context }: Route.ActionArgs) { - const check = await context.sessions.check( - request, - Capabilities.write_network, - ); + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.write_network); - if (!check) { - return data({ success: false }, 403); - } + if (!check) { + return data({ success: false }, 403); + } - if (!context.hs.writable()) { - return data({ success: false }, 403); - } + if (!context.hs.writable()) { + return data({ success: false }, 403); + } - // We only need it for health checks which don't require auth - const api = context.hsApi.getRuntimeClient('fake-api-key'); + // We only need it for health checks which don't require auth + const api = context.hsApi.getRuntimeClient("fake-api-key"); - const formData = await request.formData(); - const action = formData.get('action_id')?.toString(); - if (!action) { - return data({ success: false }, 400); - } + const formData = await request.formData(); + const action = formData.get("action_id")?.toString(); + if (!action) { + return data({ success: false }, 400); + } - switch (action) { - case 'rename_tailnet': { - const newName = formData.get('new_name')?.toString(); - if (!newName) { - return data({ success: false }, 400); - } + switch (action) { + case "rename_tailnet": { + const newName = formData.get("new_name")?.toString(); + if (!newName) { + return data({ success: false }, 400); + } - await context.hs.patch([ - { - path: 'dns.base_domain', - value: newName, - }, - ]); + await context.hs.patch([ + { + path: "dns.base_domain", + value: newName, + }, + ]); - await context.integration?.onConfigChange(api); - return { message: 'Tailnet renamed successfully' }; - } - case 'toggle_magic': { - const newState = formData.get('new_state')?.toString(); - if (!newState) { - return data({ success: false }, 400); - } + await context.integration?.onConfigChange(api); + return { message: "Tailnet renamed successfully" }; + } + case "toggle_magic": { + const newState = formData.get("new_state")?.toString(); + if (!newState) { + return data({ success: false }, 400); + } - await context.hs.patch([ - { - path: 'dns.magic_dns', - value: newState === 'enabled', - }, - ]); + await context.hs.patch([ + { + path: "dns.magic_dns", + value: newState === "enabled", + }, + ]); - await context.integration?.onConfigChange(api); - return { message: 'Magic DNS state updated successfully' }; - } - case 'remove_ns': { - const config = context.hs.c!; - const ns = formData.get('ns')?.toString(); - const splitName = formData.get('split_name')?.toString(); + await context.integration?.onConfigChange(api); + return { message: "Magic DNS state updated successfully" }; + } + case "remove_ns": { + const config = context.hs.c!; + const ns = formData.get("ns")?.toString(); + const splitName = formData.get("split_name")?.toString(); - if (!ns || !splitName) { - return data({ success: false }, 400); - } + if (!ns || !splitName) { + return data({ success: false }, 400); + } - if (splitName === 'global') { - const servers = config.dns.nameservers.global.filter((i) => i !== ns); + if (splitName === "global") { + const servers = config.dns.nameservers.global.filter((i) => i !== ns); - await context.hs.patch([ - { - path: 'dns.nameservers.global', - value: servers, - }, - ]); - } else { - const splits = config.dns.nameservers.split; - const servers = splits[splitName].filter((i) => i !== ns); + await context.hs.patch([ + { + path: "dns.nameservers.global", + value: servers, + }, + ]); + } else { + const splits = config.dns.nameservers.split; + const servers = splits[splitName].filter((i) => i !== ns); - await context.hs.patch([ - { - path: `dns.nameservers.split."${splitName}"`, - value: servers.length > 0 ? servers : null, - }, - ]); - } + await context.hs.patch([ + { + path: `dns.nameservers.split."${splitName}"`, + value: servers.length > 0 ? servers : null, + }, + ]); + } - await context.integration?.onConfigChange(api); - return { message: 'Nameserver removed successfully' }; - } - case 'add_ns': { - const config = context.hs.c!; - const ns = formData.get('ns')?.toString(); - const splitName = formData.get('split_name')?.toString(); + await context.integration?.onConfigChange(api); + return { message: "Nameserver removed successfully" }; + } + case "add_ns": { + const config = context.hs.c!; + const ns = formData.get("ns")?.toString(); + const splitName = formData.get("split_name")?.toString(); - if (!ns || !splitName) { - return data({ success: false }, 400); - } + if (!ns || !splitName) { + return data({ success: false }, 400); + } - if (splitName === 'global') { - const servers = config.dns.nameservers.global; - servers.push(ns); + if (splitName === "global") { + const servers = config.dns.nameservers.global; + servers.push(ns); - await context.hs.patch([ - { - path: 'dns.nameservers.global', - value: servers, - }, - ]); - } else { - const splits = config.dns.nameservers.split; - const servers = splits[splitName] ?? []; - servers.push(ns); + await context.hs.patch([ + { + path: "dns.nameservers.global", + value: servers, + }, + ]); + } else { + const splits = config.dns.nameservers.split; + const servers = splits[splitName] ?? []; + servers.push(ns); - await context.hs.patch([ - { - path: `dns.nameservers.split."${splitName}"`, - value: servers, - }, - ]); - } + await context.hs.patch([ + { + path: `dns.nameservers.split."${splitName}"`, + value: servers, + }, + ]); + } - await context.integration?.onConfigChange(api); - return { message: 'Nameserver added successfully' }; - } - case 'remove_domain': { - const config = context.hs.c!; - const domain = formData.get('domain')?.toString(); - if (!domain) { - return data({ success: false }, 400); - } + await context.integration?.onConfigChange(api); + return { message: "Nameserver added successfully" }; + } + case "remove_domain": { + const config = context.hs.c!; + const domain = formData.get("domain")?.toString(); + if (!domain) { + return data({ success: false }, 400); + } - const domains = config.dns.search_domains.filter((i) => i !== domain); - await context.hs.patch([ - { - path: 'dns.search_domains', - value: domains, - }, - ]); + const domains = config.dns.search_domains.filter((i) => i !== domain); + await context.hs.patch([ + { + path: "dns.search_domains", + value: domains, + }, + ]); - await context.integration?.onConfigChange(api); - return { message: 'Domain removed successfully' }; - } - case 'add_domain': { - const config = context.hs.c!; - const domain = formData.get('domain')?.toString(); - if (!domain) { - return data({ success: false }, 400); - } + await context.integration?.onConfigChange(api); + return { message: "Domain removed successfully" }; + } + case "add_domain": { + const config = context.hs.c!; + const domain = formData.get("domain")?.toString(); + if (!domain) { + return data({ success: false }, 400); + } - const domains = config.dns.search_domains; - domains.push(domain); + const domains = config.dns.search_domains; + domains.push(domain); - await context.hs.patch([ - { - path: 'dns.search_domains', - value: domains, - }, - ]); + await context.hs.patch([ + { + path: "dns.search_domains", + value: domains, + }, + ]); - await context.integration?.onConfigChange(api); - return { message: 'Domain added successfully' }; - } - case 'remove_record': { - const recordName = formData.get('record_name')?.toString(); - const recordType = formData.get('record_type')?.toString(); + await context.integration?.onConfigChange(api); + return { message: "Domain added successfully" }; + } + case "remove_record": { + const recordName = formData.get("record_name")?.toString(); + const recordType = formData.get("record_type")?.toString(); - if (!recordName || !recordType) { - return data({ success: false }, 400); - } + if (!recordName || !recordType) { + return data({ success: false }, 400); + } - // Value is not needed for removal - const restart = await context.hs.removeDNS({ - name: recordName, - type: recordType, - value: '', - }); + // Value is not needed for removal + const restart = await context.hs.removeDNS({ + name: recordName, + type: recordType, + value: "", + }); - if (!restart) { - return; - } + if (!restart) { + return; + } - await context.integration?.onConfigChange(api); - return { message: 'DNS record removed successfully' }; - } - case 'add_record': { - const recordName = formData.get('record_name')?.toString(); - const recordType = formData.get('record_type')?.toString(); - const recordValue = formData.get('record_value')?.toString(); + await context.integration?.onConfigChange(api); + return { message: "DNS record removed successfully" }; + } + case "add_record": { + const recordName = formData.get("record_name")?.toString(); + const recordType = formData.get("record_type")?.toString(); + const recordValue = formData.get("record_value")?.toString(); - if (!recordName || !recordType || !recordValue) { - return data({ success: false }, 400); - } + if (!recordName || !recordType || !recordValue) { + return data({ success: false }, 400); + } - const restart = await context.hs.addDNS({ - name: recordName, - type: recordType, - value: recordValue, - }); + const restart = await context.hs.addDNS({ + name: recordName, + type: recordType, + value: recordValue, + }); - if (!restart) { - return; - } + if (!restart) { + return; + } - await context.integration?.onConfigChange(api); - return { message: 'DNS record added successfully' }; - } - case 'override_dns': { - const override = formData.get('override_dns')?.toString(); - if (!override) { - return data({ success: false }, 400); - } + await context.integration?.onConfigChange(api); + return { message: "DNS record added successfully" }; + } + case "override_dns": { + const override = formData.get("override_dns")?.toString(); + if (!override) { + return data({ success: false }, 400); + } - const overrideValue = override === 'true'; - await context.hs.patch([ - { - path: 'dns.override_local_dns', - value: overrideValue, - }, - ]); + const overrideValue = override === "true"; + await context.hs.patch([ + { + path: "dns.override_local_dns", + value: overrideValue, + }, + ]); - await context.integration?.onConfigChange(api); - return { message: 'DNS override updated successfully' }; - } - default: - return data({ success: false }, 400); - } + await context.integration?.onConfigChange(api); + return { message: "DNS override updated successfully" }; + } + default: + return data({ success: false }, 400); + } } diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index 6648274..6d60501 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -1,115 +1,103 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; -import { useLoaderData } from 'react-router'; -import Code from '~/components/Code'; -import Notice from '~/components/Notice'; -import type { LoadContext } from '~/server'; -import { Capabilities } from '~/server/web/roles'; -import ManageDomains from './components/manage-domains'; -import ManageNS from './components/manage-ns'; -import ManageRecords from './components/manage-records'; -import RenameTailnet from './components/rename-tailnet'; -import ToggleMagic from './components/toggle-magic'; -import { dnsAction } from './dns-actions'; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { useLoaderData } from "react-router"; + +import Code from "~/components/Code"; +import Notice from "~/components/Notice"; +import type { LoadContext } from "~/server"; +import { Capabilities } from "~/server/web/roles"; + +import ManageDomains from "./components/manage-domains"; +import ManageNS from "./components/manage-ns"; +import ManageRecords from "./components/manage-records"; +import RenameTailnet from "./components/rename-tailnet"; +import ToggleMagic from "./components/toggle-magic"; +import { dnsAction } from "./dns-actions"; // We do not want to expose every config value -export async function loader({ - request, - context, -}: LoaderFunctionArgs) { - if (!context.hs.readable()) { - throw new Error('No configuration is available'); - } +export async function loader({ request, context }: LoaderFunctionArgs) { + if (!context.hs.readable()) { + 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 principal = await context.auth.require(request); + const check = context.auth.can(principal, 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 writablePermission = context.auth.can(principal, Capabilities.write_network); - const config = context.hs.c!; - const dns = { - prefixes: config.prefixes, - magicDns: config.dns.magic_dns, - baseDomain: config.dns.base_domain, - nameservers: config.dns.nameservers.global, - splitDns: config.dns.nameservers.split, - searchDomains: config.dns.search_domains, - overrideDns: config.dns.override_local_dns, - extraRecords: context.hs.d, - }; + const config = context.hs.c!; + const dns = { + prefixes: config.prefixes, + magicDns: config.dns.magic_dns, + baseDomain: config.dns.base_domain, + nameservers: config.dns.nameservers.global, + splitDns: config.dns.nameservers.split, + searchDomains: config.dns.search_domains, + overrideDns: config.dns.override_local_dns, + extraRecords: context.hs.d, + }; - return { - ...dns, - access: writablePermission, - writable: context.hs.writable(), - }; + return { + ...dns, + access: writablePermission, + writable: context.hs.writable(), + }; } export async function action(data: ActionFunctionArgs) { - return dnsAction(data); + return dnsAction(data); } export default function Page() { - const data = useLoaderData(); + const data = useLoaderData(); - const allNs: Record = {}; - for (const key of Object.keys(data.splitDns)) { - allNs[key] = data.splitDns[key]; - } + const allNs: Record = {}; + for (const key of Object.keys(data.splitDns)) { + allNs[key] = data.splitDns[key]; + } - allNs.global = data.nameservers; - const isDisabled = data.access === false || data.writable === false; + allNs.global = data.nameservers; + const isDisabled = data.access === false || data.writable === false; - return ( -
- {data.writable ? undefined : ( - - The Headscale configuration is read-only. You cannot make changes to - the configuration - - )} - {data.access ? undefined : ( - - Your permissions do not allow you to modify the DNS settings for this - tailnet. - - )} - - - - + return ( +
+ {data.writable ? undefined : ( + + The Headscale configuration is read-only. You cannot make changes to the configuration + + )} + {data.access ? undefined : ( + + Your permissions do not allow you to modify the DNS settings for this tailnet. + + )} + + + + -
-

Magic DNS

-

- Automatically register domain names for each device on the tailnet. - Devices will be accessible at{' '} - - [device]. - {data.baseDomain} - {' '} - when Magic DNS is enabled. -

- -
-
- ); +
+

Magic DNS

+

+ Automatically register domain names for each device on the tailnet. Devices will be + accessible at{" "} + + [device]. + {data.baseDomain} + {" "} + when Magic DNS is enabled. +

+ +
+
+ ); } diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index 8952ec1..0b8254f 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -6,11 +6,12 @@ import { Capabilities } from "~/server/web/roles"; import type { Route } from "./+types/machine"; export async function machineAction({ request, context }: Route.ActionArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.write_machines); + const principal = await context.auth.require(request); const formData = await request.formData(); - const api = context.hsApi.getRuntimeClient(session.api_key); + const api = context.hsApi.getRuntimeClient( + context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey), + ); const action = formData.get("action_id")?.toString(); if (!action) { @@ -21,7 +22,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { // Fast track register since it doesn't require an existing machine if (action === "register") { - if (!check) { + if (!context.auth.can(principal, Capabilities.write_machines)) { throw data("You do not have permission to manage machines", { status: 403, }); @@ -60,10 +61,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { }); } - // Tag-only nodes (Headscale 0.28+) have no user — only role-based permissions apply - const nodeOwnerId = node.user?.providerId?.split("/").pop(); - const isOwner = nodeOwnerId !== undefined && nodeOwnerId === session.user.subject; - if (!isOwner && !check) { + if (!context.auth.canManageNode(principal, node)) { throw data("You do not have permission to act on this machine", { status: 403, }); diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 79d3018..165d9f1 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -21,7 +21,7 @@ import Routes from "./dialogs/routes"; import { machineAction } from "./machine-actions"; export async function loader({ request, params, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); + const principal = await context.auth.require(request); if (!params.id) { throw new Error("No machine ID provided"); } @@ -37,7 +37,9 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { } } - const api = context.hsApi.getRuntimeClient(session.api_key); + const api = context.hsApi.getRuntimeClient( + context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey), + ); const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); const node = nodes.find((node) => node.id === params.id); diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 5158f39..3e0d3a2 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -10,30 +10,24 @@ import cn from "~/utils/cn"; import { mapNodes, sortNodeTags } from "~/utils/node-info"; import type { Route } from "./+types/overview"; - import MachineRow from "./components/machine-row"; import NewMachine from "./dialogs/new"; import { machineAction } from "./machine-actions"; export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const user = session.user; - if (!user) { - throw new Error("Missing user session. Please log in again."); - } + const principal = await context.auth.require(request); - const check = await context.sessions.check(request, Capabilities.read_machines); - - if (!check) { - // Not authorized to view this page + if (!context.auth.can(principal, Capabilities.read_machines)) { 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_machines); + const writablePermission = context.auth.can(principal, Capabilities.write_machines); - const api = context.hsApi.getRuntimeClient(session.api_key); + const api = context.hsApi.getRuntimeClient( + context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey), + ); const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); let magic: string | undefined; @@ -56,8 +50,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { publicServer: context.config.headscale.public_url, agent: context.agents?.agentID(), writable: writablePermission, - preAuth: await context.sessions.check(request, Capabilities.generate_authkeys), - subject: user.subject, + preAuth: context.auth.can(principal, Capabilities.generate_authkeys), + headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined, supportsNodeOwnerChange: supportsNodeOwnerChange, }; } @@ -363,7 +357,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { isDisabled={ loaderData.writable ? false // If the user has write permissions, they can edit all machines - : node.user?.providerId?.split("/").pop() !== loaderData.subject + : node.user?.id !== loaderData.headscaleUserId } key={node.id} magic={loaderData.magic} diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index 89a1db5..8702a8e 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -1,15 +1,17 @@ import { data } from "react-router"; +import { getOidcSubject } from "~/server/web/headscale-identity"; import { Capabilities } from "~/server/web/roles"; import type { Route } from "./+types/overview"; export async function authKeysAction({ request, context }: Route.ActionArgs) { - const session = await context.sessions.auth(request); - const api = context.hsApi.getRuntimeClient(session.api_key); + const principal = await context.auth.require(request); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); - const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys); - const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys); + const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); + const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys); if (!canGenerateAny && !canGenerateOwn) { throw data("You do not have permission to manage pre-auth keys", { @@ -23,8 +25,8 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) { if (!targetUser) { throw data("User not found.", { status: 404 }); } - const targetSubject = targetUser.providerId?.split("/").pop(); - if (targetSubject !== session.user.subject) { + const targetSubject = getOidcSubject(targetUser); + if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) { throw data("You do not have permission to manage this user's pre-auth keys", { status: 403, }); diff --git a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx index e9420f3..5811151 100644 --- a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx +++ b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx @@ -17,11 +17,15 @@ interface AddAuthKeyProps { users: User[]; url: string; selfServiceOnly: boolean; - currentSubject: string; + currentSubject?: string; } -function findCurrentUser(users: User[], subject: string): User | undefined { - return users.find((u) => u.providerId?.split("/").pop() === subject); +function findCurrentUser(users: User[], subject: string | undefined): User | undefined { + if (!subject) return undefined; + return users.find((u) => { + if (u.provider !== "oidc" || !u.providerId) return false; + return u.providerId.split("/").pop() === subject; + }); } export default function AddAuthKey({ diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index 301ce92..4d82991 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -19,8 +19,9 @@ import AuthKeyRow from "./auth-key-row"; import AddAuthKey from "./dialogs/add-auth-key"; export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const api = context.hsApi.getRuntimeClient(session.api_key); + const principal = await context.auth.require(request); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); const users = await api.getUsers(); @@ -83,8 +84,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { .map(({ user, error }) => ({ user, error })); } - const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys); - const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys); + const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); + const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys); return { keys, @@ -92,7 +93,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { users, access: canGenerateAny || canGenerateOwn, selfServiceOnly: !canGenerateAny && canGenerateOwn, - currentSubject: session.user.subject, + currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined, url: context.config.headscale.public_url ?? context.config.headscale.url, }; } diff --git a/app/routes/settings/overview.tsx b/app/routes/settings/overview.tsx index a81593f..f7dfe24 100644 --- a/app/routes/settings/overview.tsx +++ b/app/routes/settings/overview.tsx @@ -6,7 +6,7 @@ import Link from "~/components/Link"; import type { Route } from "./+types/overview"; export async function loader({ context }: Route.LoaderArgs) { - const oidcConnector = await context.oidcConnector?.get(); + const oidcConnector = await context.oidc?.connector.get(); return { config: context.hs.writable(), isOidcEnabled: oidcConnector?.isValid ?? false, diff --git a/app/routes/settings/restrictions/actions.ts b/app/routes/settings/restrictions/actions.ts index 26acd1b..c953d80 100644 --- a/app/routes/settings/restrictions/actions.ts +++ b/app/routes/settings/restrictions/actions.ts @@ -1,198 +1,189 @@ -import { data } from 'react-router'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; -export async function restrictionAction({ - request, - context, -}: Route.ActionArgs) { - const check = await context.sessions.check( - request, - Capabilities.configure_iam, - ); +import { Capabilities } from "~/server/web/roles"; - if (!check) { - throw data('You do not have permission to modify IAM settings.', { - status: 403, - }); - } +import type { Route } from "./+types/overview"; - if (!context.hs.writable()) { - throw data('The Headscale configuration file is not editable.', { - status: 403, - }); - } +export async function restrictionAction({ request, context }: Route.ActionArgs) { + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.configure_iam); - const formData = await request.formData(); - const action = formData.get('action_id')?.toString(); - if (!action) { - throw data('No action provided.', { - status: 400, - }); - } + if (!check) { + throw data("You do not have permission to modify IAM settings.", { + status: 403, + }); + } - // We only need healthchecks which don't rely on an API key - const api = context.hsApi.getRuntimeClient('fake-api-key'); - switch (action) { - case 'add_domain': { - const domain = formData.get('domain')?.toString()?.trim(); - if (!domain) { - throw data('No domain provided.', { - status: 400, - }); - } + if (!context.hs.writable()) { + throw data("The Headscale configuration file is not editable.", { + status: 403, + }); + } - const domains = [ - ...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]), - ]; + const formData = await request.formData(); + const action = formData.get("action_id")?.toString(); + if (!action) { + throw data("No action provided.", { + status: 400, + }); + } - await context.hs.patch([ - { - path: 'oidc.allowed_domains', - value: domains, - }, - ]); + // We only need healthchecks which don't rely on an API key + const api = context.hsApi.getRuntimeClient("fake-api-key"); + switch (action) { + case "add_domain": { + const domain = formData.get("domain")?.toString()?.trim(); + if (!domain) { + throw data("No domain provided.", { + status: 400, + }); + } - context.integration?.onConfigChange(api); - return data('Domain added successfully.'); - } + const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])]; - case 'remove_domain': { - const domain = formData.get('domain')?.toString()?.trim(); - if (!domain) { - throw data('No domain provided.', { - status: 400, - }); - } + await context.hs.patch([ + { + path: "oidc.allowed_domains", + value: domains, + }, + ]); - 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, - }); - } + context.integration?.onConfigChange(api); + return data("Domain added successfully."); + } - // 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(api); - return data('Domain removed successfully.'); - } + case "remove_domain": { + const domain = formData.get("domain")?.toString()?.trim(); + if (!domain) { + throw data("No domain provided.", { + status: 400, + }); + } - case 'add_group': { - const group = formData.get('group')?.toString()?.trim(); - if (!group) { - throw data('No group 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, + }); + } - const groups = [ - ...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]), - ]; + // 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(api); + return data("Domain removed successfully."); + } - await context.hs.patch([ - { - path: 'oidc.allowed_groups', - value: groups, - }, - ]); + case "add_group": { + const group = formData.get("group")?.toString()?.trim(); + if (!group) { + throw data("No group provided.", { + status: 400, + }); + } - context.integration?.onConfigChange(api); - return data('Group added successfully.'); - } + const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])]; - case 'remove_group': { - const group = formData.get('group')?.toString()?.trim(); - if (!group) { - throw data('No group provided.', { - status: 400, - }); - } + await context.hs.patch([ + { + path: "oidc.allowed_groups", + value: groups, + }, + ]); - 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, - }); - } + context.integration?.onConfigChange(api); + return data("Group added successfully."); + } - // 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, - }, - ]); + case "remove_group": { + const group = formData.get("group")?.toString()?.trim(); + if (!group) { + throw data("No group provided.", { + status: 400, + }); + } - context.integration?.onConfigChange(api); - return data('Group removed successfully.'); - } + 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, + }); + } - case 'add_user': { - const user = formData.get('user')?.toString()?.trim(); - if (!user) { - throw data('No user provided.', { - 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, + }, + ]); - const users = [ - ...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]), - ]; + context.integration?.onConfigChange(api); + return data("Group removed successfully."); + } - await context.hs.patch([ - { - path: 'oidc.allowed_users', - value: users, - }, - ]); + case "add_user": { + const user = formData.get("user")?.toString()?.trim(); + if (!user) { + throw data("No user provided.", { + status: 400, + }); + } - context.integration?.onConfigChange(api); - return data('User added successfully.'); - } + const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])]; - case 'remove_user': { - const user = formData.get('user')?.toString()?.trim(); - if (!user) { - throw data('No user provided.', { - status: 400, - }); - } + await context.hs.patch([ + { + path: "oidc.allowed_users", + value: users, + }, + ]); - 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, - }); - } + context.integration?.onConfigChange(api); + return data("User added successfully."); + } - // 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, - }, - ]); + case "remove_user": { + const user = formData.get("user")?.toString()?.trim(); + if (!user) { + throw data("No user provided.", { + status: 400, + }); + } - context.integration?.onConfigChange(api); - return data('User removed successfully.'); - } + 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, + }); + } - default: { - throw data('Invalid action provided.', { - 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(api); + return data("User removed successfully."); + } + + default: { + throw data("Invalid action provided.", { + status: 400, + }); + } + } } diff --git a/app/routes/settings/restrictions/overview.tsx b/app/routes/settings/restrictions/overview.tsx index 5dabccc..f177ad0 100644 --- a/app/routes/settings/restrictions/overview.tsx +++ b/app/routes/settings/restrictions/overview.tsx @@ -1,108 +1,91 @@ -import { data, Link as RemixLink } from 'react-router'; -import Link from '~/components/Link'; -import Notice from '~/components/Notice'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; -import { restrictionAction } from './actions'; -import AddDomain from './dialogs/add-domain'; -import AddGroup from './dialogs/add-group'; -import AddUser from './dialogs/add-user'; -import RestrictionTable from './table'; +import { data, Link as RemixLink } from "react-router"; + +import Link from "~/components/Link"; +import Notice from "~/components/Notice"; +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; +import { restrictionAction } from "./actions"; +import AddDomain from "./dialogs/add-domain"; +import AddGroup from "./dialogs/add-group"; +import AddUser from "./dialogs/add-user"; +import RestrictionTable from "./table"; export async function loader({ request, context }: Route.LoaderArgs) { - 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, - }); - } + const principal = await context.auth.require(request); + const check = context.auth.can(principal, 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, - }); - } + 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)], - }, - }; + return { + access: context.auth.can(principal, 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 const action = restrictionAction; -export default function Page({ - loaderData: { access, writable, settings }, -}: Route.ComponentProps) { - const isDisabled = writable ? !access : true; +export default function Page({ loaderData: { access, writable, settings } }: Route.ComponentProps) { + 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 - -

-
- - - - - - - - - -
- ); + 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/routes/ssh/console.tsx b/app/routes/ssh/console.tsx index b68c223..d2fabab 100644 --- a/app/routes/ssh/console.tsx +++ b/app/routes/ssh/console.tsx @@ -6,10 +6,10 @@ import { data, type ShouldRevalidateFunction, useSubmit } from "react-router"; import { ExternalScriptsHandle } from "remix-utils/external-scripts"; import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema"; +import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import { useLiveData } from "~/utils/live-data"; import type { Route } from "./+types/console"; - import UserPrompt from "./user-prompt"; import XTerm from "./xterm.client"; @@ -35,28 +35,23 @@ export async function loader({ request, context }: Route.LoaderArgs) { throw data("WebSSH is only available with the Headplane agent integration", 400); } - const session = await context.sessions.auth(request); - if (session.user.subject === "unknown-non-oauth") { + const principal = await context.auth.require(request); + if (principal.kind === "api_key") { throw data("Only OAuth users are allowed to use WebSSH", 403); } - const api = context.hsApi.getRuntimeClient(session.api_key); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); const users = await api.getUsers(); // MARK: This assumes that a user has authenticated with Headscale first // Since the only way to enforce permissions via ACLs is to generate a // pre-authkey which REQUIRES a user ID, meaning the user has to have // authenticated with Headscale first. - const lookup = users.find((u) => { - const subject = u.providerId?.split("/").pop(); - if (!subject) { - return false; - } - return subject === session.user.subject; - }); + const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email); if (!lookup) { - throw data(`User with subject ${session.user.subject} not found within Headscale`, 404); + throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404); } const preAuthKey = await api.createPreAuthKey( @@ -157,7 +152,7 @@ function generateHostname(username: string) { } export async function action({ request, context }: Route.ActionArgs) { - await context.sessions.auth(request); + await context.auth.require(request); if (!context.agents?.agentID()) { throw data("WebSSH is only available with the Headplane agent integration", 400); } diff --git a/app/routes/users/onboarding-skip.tsx b/app/routes/users/onboarding-skip.tsx index f909ab5..42c261b 100644 --- a/app/routes/users/onboarding-skip.tsx +++ b/app/routes/users/onboarding-skip.tsx @@ -1,20 +1,49 @@ -import { eq } from 'drizzle-orm'; -import { redirect } from 'react-router'; -import { users } from '~/server/db/schema'; -import type { Route } from './+types/onboarding-skip'; +import { eq } from "drizzle-orm"; +import { redirect } from "react-router"; + +import { users } from "~/server/db/schema"; + +import type { Route } from "./+types/onboarding-skip"; export async function loader({ request, context }: Route.LoaderArgs) { - try { - const { user } = await context.sessions.auth(request); - await context.db - .update(users) - .set({ - onboarded: true, - }) - .where(eq(users.sub, user.subject)); + try { + const principal = await context.auth.require(request); + if (principal.kind !== "oidc") { + return redirect("/machines"); + } - return redirect('/machines'); - } catch { - return redirect('/login'); - } + await context.db + .update(users) + .set({ onboarded: true }) + .where(eq(users.sub, principal.user.subject)); + + return redirect("/machines"); + } catch { + return redirect("/login"); + } +} + +export async function action({ request, context }: Route.ActionArgs) { + try { + const principal = await context.auth.require(request); + if (principal.kind !== "oidc") { + return redirect("/machines"); + } + + const formData = await request.formData(); + const headscaleUserId = formData.get("headscale_user_id")?.toString(); + + if (headscaleUserId) { + await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId); + } + + await context.db + .update(users) + .set({ onboarded: true }) + .where(eq(users.sub, principal.user.subject)); + + return redirect("/machines"); + } catch { + return redirect("/login"); + } } diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index dbbedd6..dd84af2 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -1,27 +1,29 @@ import { Icon } from "@iconify/react"; import { ArrowRight } from "lucide-react"; import { useEffect } from "react"; -import { NavLink } from "react-router"; +import { Form, NavLink } from "react-router"; import Button from "~/components/Button"; import Card from "~/components/Card"; import Link from "~/components/Link"; import Options from "~/components/Options"; import StatusCircle from "~/components/StatusCircle"; +import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import { Machine } from "~/types"; import cn from "~/utils/cn"; import { useLiveData } from "~/utils/live-data"; import log from "~/utils/log"; import toast from "~/utils/toast"; +import { getUserDisplayName } from "~/utils/user"; import type { Route } from "./+types/onboarding"; export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); + const principal = await context.auth.require(request); + if (principal.kind !== "oidc") { + throw new Error("Onboarding is only available for OIDC users."); + } - // Try to determine the OS split between Linux, Windows, macOS, iOS, and Android - // We need to convert this to a known value to return it to the client so we can - // automatically tab to the correct download button. const userAgent = request.headers.get("user-agent"); const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/); let osValue = "linux"; @@ -47,45 +49,58 @@ export async function loader({ request, context }: Route.LoaderArgs) { break; } - const api = context.hsApi.getRuntimeClient(session.api_key); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + + const hsUserId = principal.user.headscaleUserId; let firstMachine: Machine | undefined; + let needsUserLink = false; + let headscaleUsers: { id: string; name: string }[] = []; + try { - const nodes = await api.getNodes(); - const node = nodes.find((n) => { - // Tag-only nodes have no user - if (!n.user || n.user.provider !== "oidc") { - return false; + const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); + + if (hsUserId) { + firstMachine = nodes.find((n) => n.user?.id === hsUserId); + } else { + const matched = findHeadscaleUserBySubject( + apiUsers, + principal.user.subject, + principal.profile.email, + ); + + if (matched) { + await context.auth.linkHeadscaleUser(principal.user.id, matched.id); + firstMachine = nodes.find((n) => n.user?.id === matched.id); + } else { + needsUserLink = true; + headscaleUsers = apiUsers.map((u) => ({ + id: u.id, + name: getUserDisplayName(u), + })); } - - // For some reason, headscale makes providerID a url where the - // last component is the subject, so we need to strip that out - const subject = n.user.providerId?.split("/").pop(); - if (!subject) { - return false; - } - - if (subject !== session.user.subject) { - return false; - } - - return true; - }); - - firstMachine = node; + } } catch (e) { - // If we cannot lookup nodes, we cannot proceed log.debug("api", "Failed to lookup nodes %o", e); } return { - user: session.user, + user: { + subject: principal.user.subject, + name: principal.profile.name, + email: principal.profile.email, + username: principal.profile.username, + picture: principal.profile.picture, + }, osValue, firstMachine, + needsUserLink, + headscaleUsers, }; } export default function Page({ - loaderData: { user, osValue, firstMachine }, + loaderData: { user, osValue, firstMachine, needsUserLink, headscaleUsers }, }: Route.ComponentProps) { const { pause, resume } = useLiveData(); useEffect(() => { @@ -107,6 +122,36 @@ export default function Page({ return (
+ {needsUserLink && headscaleUsers.length > 0 ? ( + + Link your Headscale account + + Headplane couldn't automatically match your SSO identity to a Headscale user. Select + which Headscale user you are to continue. + +
+ + +
+
+ ) : undefined} Welcome! diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index eb14eb0..66d0a62 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,13 +1,13 @@ import { createHash } from "node:crypto"; + import { useEffect, useState } from "react"; -import type { Machine, User } from "~/types"; - +import { getOidcSubject } from "~/server/web/headscale-identity"; import { Capabilities } from "~/server/web/roles"; +import type { Machine, User } from "~/types"; import cn from "~/utils/cn"; import type { Route } from "./+types/overview"; - import ManageBanner from "./components/manage-banner"; import UserRow from "./components/user-row"; import { userAction } from "./user-actions"; @@ -17,8 +17,8 @@ interface UserMachine extends User { } export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.read_users); + const principal = await context.auth.require(request); + const check = await context.auth.can(principal, Capabilities.read_users); if (!check) { // Not authorized to view this page throw new Error( @@ -26,9 +26,10 @@ export async function loader({ request, context }: Route.LoaderArgs) { ); } - const writablePermission = await context.sessions.check(request, Capabilities.write_users); + const writablePermission = await context.auth.can(principal, Capabilities.write_users); - const api = context.hsApi.getRuntimeClient(session.api_key); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); const users = apiUsers.map((user) => ({ @@ -56,22 +57,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { return "no-oidc"; } - if (user.provider === "oidc" && user.providerId) { - // For some reason, headscale makes providerID a url where the - // last component is the subject, so we need to strip that out - const subject = user.providerId.split("/").pop(); - if (!subject) { - return "invalid-oidc"; - } - - const role = await context.sessions.roleForSubject(subject); - return role ?? "no-role"; + const subject = getOidcSubject(user); + if (!subject) { + return "invalid-oidc"; } - // No role means the user is not registered in Headplane, but they - // are in Headscale. We also need to handle what happens if someone - // logs into the UI and they don't have a Headscale setup. - return "no-role"; + const role = await context.auth.roleForSubject(subject); + return role ?? "no-role"; }), ); diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index 901d4b0..4f9a3f9 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -1,115 +1,112 @@ -import { data } from 'react-router'; -import { Capabilities, Roles } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; + +import { getOidcSubject } from "~/server/web/headscale-identity"; +import { Capabilities } from "~/server/web/roles"; +import type { Role } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; export async function userAction({ request, context }: Route.ActionArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.write_users); - if (!check) { - throw data('You do not have permission to update users', { - status: 403, - }); - } + const principal = await context.auth.require(request); + const check = await context.auth.can(principal, Capabilities.write_users); + if (!check) { + throw data("You do not have permission to update users", { + status: 403, + }); + } - const formData = await request.formData(); - const action = formData.get('action_id')?.toString(); - if (!action) { - throw data('Missing `action_id` in the form data.', { - status: 404, - }); - } + const formData = await request.formData(); + const action = formData.get("action_id")?.toString(); + if (!action) { + throw data("Missing `action_id` in the form data.", { + status: 404, + }); + } - const api = context.hsApi.getRuntimeClient(session.api_key); - switch (action) { - case 'create_user': { - const name = formData.get('username')?.toString(); - const displayName = formData.get('display_name')?.toString(); - const email = formData.get('email')?.toString(); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + switch (action) { + case "create_user": { + const name = formData.get("username")?.toString(); + const displayName = formData.get("display_name")?.toString(); + const email = formData.get("email")?.toString(); - if (!name) { - throw data('Missing `username` in the form data.', { - status: 400, - }); - } + if (!name) { + throw data("Missing `username` in the form data.", { + status: 400, + }); + } - await api.createUser(name, email, displayName); - return { message: 'User created successfully' }; - } - case 'delete_user': { - const userId = formData.get('user_id')?.toString(); - if (!userId) { - throw data('Missing `user_id` in the form data.', { - status: 400, - }); - } + await api.createUser(name, email, displayName); + return { message: "User created successfully" }; + } + case "delete_user": { + const userId = formData.get("user_id")?.toString(); + if (!userId) { + throw data("Missing `user_id` in the form data.", { + status: 400, + }); + } - await api.deleteUser(userId); - return { message: 'User deleted successfully' }; - } - case 'rename_user': { - const userId = formData.get('user_id')?.toString(); - const newName = formData.get('new_name')?.toString(); - if (!userId || !newName) { - return data({ success: false }, 400); - } + await api.deleteUser(userId); + return { message: "User deleted successfully" }; + } + case "rename_user": { + const userId = formData.get("user_id")?.toString(); + const newName = formData.get("new_name")?.toString(); + if (!userId || !newName) { + return data({ success: false }, 400); + } - const users = await api.getUsers(userId); - const user = users.find((user) => user.id === userId); - if (!user) { - throw data(`No user found with id: ${userId}`, { status: 400 }); - } + const users = await api.getUsers(userId); + const user = users.find((user) => user.id === userId); + if (!user) { + throw data(`No user found with id: ${userId}`, { status: 400 }); + } - if (user.provider === 'oidc') { - // OIDC users cannot be renamed via this endpoint, return an error - throw data('Users managed by OIDC cannot be renamed', { - status: 403, - }); - } + if (user.provider === "oidc") { + // OIDC users cannot be renamed via this endpoint, return an error + throw data("Users managed by OIDC cannot be renamed", { + status: 403, + }); + } - await api.renameUser(userId, newName); - return { message: 'User renamed successfully' }; - } - case 'reassign_user': { - const userId = formData.get('user_id')?.toString(); - const newRole = formData.get('new_role')?.toString(); - if (!userId || !newRole) { - throw data('Missing `user_id` or `new_role` in the form data.', { - status: 400, - }); - } + await api.renameUser(userId, newName); + return { message: "User renamed successfully" }; + } + case "reassign_user": { + const userId = formData.get("user_id")?.toString(); + const newRole = formData.get("new_role")?.toString(); + if (!userId || !newRole) { + throw data("Missing `user_id` or `new_role` in the form data.", { + status: 400, + }); + } - const users = await api.getUsers(userId); - const user = users.find((user) => user.id === userId); - if (!user?.providerId) { - throw data('Specified user is not an OIDC user', { - status: 400, - }); - } + const users = await api.getUsers(userId); + const user = users.find((user) => user.id === userId); + if (!user) { + throw data("Specified user not found", { + status: 400, + }); + } - // For some reason, headscale makes providerID a url where the - // last component is the subject, so we need to strip that out - const subject = user.providerId?.split('/').pop(); - if (!subject) { - throw data( - 'Malformed `providerId` for the specified user. Cannot find subject.', - { status: 400 }, - ); - } + const subject = getOidcSubject(user); + if (!subject) { + throw data("Specified user is not an OIDC user or has no subject.", { status: 400 }); + } - const result = await context.sessions.reassignSubject( - subject, - newRole as keyof typeof Roles, - ); + const result = await context.auth.reassignSubject(subject, newRole as Role); - if (!result) { - throw data('Failed to reassign user role.', { status: 500 }); - } + if (!result) { + throw data("Failed to reassign user role.", { status: 500 }); + } - return { message: 'User reassigned successfully' }; - } - default: - throw data('Invalid `action_id` provided.', { - status: 400, - }); - } + return { message: "User reassigned successfully" }; + } + default: + throw data("Invalid `action_id` provided.", { + status: 400, + }); + } } diff --git a/app/server/db/pruner.ts b/app/server/db/pruner.ts index b686576..8193c73 100644 --- a/app/server/db/pruner.ts +++ b/app/server/db/pruner.ts @@ -1,50 +1,48 @@ -import { eq, isNotNull } from 'drizzle-orm'; -import log from '~/utils/log'; -import type { Route } from '../../layouts/+types/dashboard'; -import { ephemeralNodes } from './schema'; +import { eq, isNotNull } from "drizzle-orm"; -export async function pruneEphemeralNodes({ - context, - request, -}: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const ephemerals = await context.db - .select() - .from(ephemeralNodes) - .where(isNotNull(ephemeralNodes.node_key)); +import log from "~/utils/log"; - if (ephemerals.length === 0) { - log.debug('api', 'No ephemeral nodes to prune'); - return; - } +import type { Route } from "../../layouts/+types/dashboard"; +import { ephemeralNodes } from "./schema"; - const api = context.hsApi.getRuntimeClient(session.api_key); - const nodes = await api.getNodes(); - const toPrune = nodes.filter((node) => { - if (node.online) { - return false; - } +export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) { + const principal = await context.auth.require(request); + const ephemerals = await context.db + .select() + .from(ephemeralNodes) + .where(isNotNull(ephemeralNodes.node_key)); - return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key); - }); + if (ephemerals.length === 0) { + log.debug("api", "No ephemeral nodes to prune"); + return; + } - if (toPrune.length === 0) { - log.debug('api', 'No SSH nodes to prune'); - return; - } + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + const nodes = await api.getNodes(); + const toPrune = nodes.filter((node) => { + if (node.online) { + return false; + } - // Delete from the Headscale nodes list and then from the database - const promises = toPrune.map((node) => { - return async () => { - log.debug('api', `Pruning node ${node.name}`); - await api.deleteNode(node.id); + return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key); + }); - await context.db - .delete(ephemeralNodes) - .where(eq(ephemeralNodes.node_key, node.nodeKey)); - log.debug('api', `Node ${node.name} pruned successfully`); - }; - }); + if (toPrune.length === 0) { + log.debug("api", "No SSH nodes to prune"); + return; + } - await Promise.all(promises.map((p) => p())); + // Delete from the Headscale nodes list and then from the database + const promises = toPrune.map((node) => { + return async () => { + log.debug("api", `Pruning node ${node.name}`); + await api.deleteNode(node.id); + + await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey)); + log.debug("api", `Node ${node.name} pruned successfully`); + }; + }); + + await Promise.all(promises.map((p) => p())); } diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 1f24d1f..b3bc880 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -1,31 +1,50 @@ -import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { HostInfo } from '~/types'; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -export const ephemeralNodes = sqliteTable('ephemeral_nodes', { - auth_key: text('auth_key').primaryKey(), - node_key: text('node_key'), +import { HostInfo } from "~/types"; + +export const ephemeralNodes = sqliteTable("ephemeral_nodes", { + auth_key: text("auth_key").primaryKey(), + node_key: text("node_key"), }); export type EphemeralNode = typeof ephemeralNodes.$inferSelect; export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert; -export const hostInfo = sqliteTable('host_info', { - host_id: text('host_id').primaryKey(), - payload: text('payload', { mode: 'json' }).$type(), - updated_at: integer('updated_at', { mode: 'timestamp' }).$default( - () => new Date(), - ), +export const hostInfo = sqliteTable("host_info", { + host_id: text("host_id").primaryKey(), + payload: text("payload", { mode: "json" }).$type(), + updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()), }); export type HostInfoRecord = typeof hostInfo.$inferSelect; export type HostInfoInsert = typeof hostInfo.$inferInsert; -export const users = sqliteTable('users', { - id: text('id').primaryKey(), - sub: text('sub').notNull().unique(), - caps: integer('caps').notNull().default(0), - onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false), +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + sub: text("sub").notNull().unique(), + role: text("role").notNull().default("member"), + headscale_user_id: text("headscale_user_id"), + onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false), + created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()), + updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()), + last_login_at: integer("last_login_at", { mode: "timestamp" }), + + // Deprecated: kept for migration compatibility, will be removed in 1.0 + caps: integer("caps").notNull().default(0), }); -export type User = typeof users.$inferSelect; -export type UserInsert = typeof users.$inferInsert; +export type HeadplaneUser = typeof users.$inferSelect; +export type HeadplaneUserInsert = typeof users.$inferInsert; + +export const authSessions = sqliteTable("auth_sessions", { + id: text("id").primaryKey(), + kind: text("kind").notNull(), // 'oidc' | 'api_key' + user_id: text("user_id"), + api_key_hash: text("api_key_hash"), + api_key_display: text("api_key_display"), + expires_at: integer("expires_at", { mode: "timestamp" }).notNull(), + created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()), +}); + +export type AuthSessionRecord = typeof authSessions.$inferSelect; +export type AuthSessionInsert = typeof authSessions.$inferInsert; diff --git a/app/server/index.ts b/app/server/index.ts index aedfc30..b2ed5b3 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { exit, versions } from "node:process"; + import { createHonoServer } from "react-router-hono-server/node"; import log from "~/utils/log"; @@ -10,7 +11,7 @@ import { createDbClient } from "./db/client.server"; import { createHeadscaleInterface } from "./headscale/api"; import { loadHeadscaleConfig } from "./headscale/config-loader"; import { createHeadplaneAgent } from "./hp-agent"; -import { createSessionStorage } from "./web/sessions"; +import { createAuthService } from "./web/auth"; declare global { const __PREFIX__: string; @@ -60,11 +61,9 @@ const appLoadContext = { config.headscale.dns_records_path, ), - // TODO: Better cookie options in config - sessions: await createSessionStorage({ + auth: createAuthService({ secret: config.server.cookie_secret, db, - oidcUsersFile: config.oidc?.user_storage_file, cookie: { name: "_hp_auth", secure: config.server.cookie_secure, @@ -76,13 +75,16 @@ const appLoadContext = { hsApi, agents, integration: await loadIntegration(config.integration), - oidcConnector: + oidc: config.oidc && config.oidc.enabled !== false - ? createLazyOidcConnector( - config.server.base_url, - config.oidc, - hsApi.getRuntimeClient(config.oidc.headscale_api_key), - ) + ? { + apiKey: config.oidc.headscale_api_key, + connector: createLazyOidcConnector( + config.server.base_url, + config.oidc, + hsApi.getRuntimeClient(config.oidc.headscale_api_key), + ), + } : undefined, db, }; diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts new file mode 100644 index 0000000..e447971 --- /dev/null +++ b/app/server/web/auth.ts @@ -0,0 +1,429 @@ +import { createHash } from "node:crypto"; + +import { eq, lt } from "drizzle-orm"; +import { LibSQLDatabase } from "drizzle-orm/libsql/driver"; +import { createCookie } from "react-router"; +import { ulid } from "ulidx"; + +import type { Machine } from "~/types"; + +import { authSessions, users } from "../db/schema"; +import { Capabilities, type Role, Roles, capsForRole } from "./roles"; + +// ── Principal ──────────────────────────────────────────────────────── +// The per-request identity object. Discriminated on `kind` so routes +// can branch structurally instead of checking magic strings. + +export type Principal = + | { + kind: "api_key"; + sessionId: string; + displayName: string; + apiKey: string; + } + | { + kind: "oidc"; + sessionId: string; + user: { + id: string; + subject: string; + role: Role; + headscaleUserId: string | undefined; + onboarded: boolean; + }; + profile: { + name: string; + email?: string; + username?: string; + picture?: string; + }; + }; + +// ── Cookie payload ─────────────────────────────────────────────────── +// The cookie contains only a session ID + minimal profile data for +// SSR rendering. Credentials never leave the server. + +interface CookiePayload { + sid: string; + // API key is stored in the cookie ONLY for api_key sessions. + // OIDC sessions use the server-side oidc.headscale_api_key. + api_key?: string; + profile?: { + name: string; + email?: string; + username?: string; + picture?: string; + }; +} + +// ── AuthService ────────────────────────────────────────────────────── + +export interface AuthServiceOptions { + secret: string; + db: LibSQLDatabase; + cookie: { + name: string; + secure: boolean; + maxAge: number; + domain?: string; + }; +} + +export class AuthService { + private opts: AuthServiceOptions; + private requestCache = new WeakMap>(); + + constructor(opts: AuthServiceOptions) { + this.opts = opts; + } + + // ── Authentication ───────────────────────────────────────────── + + /** + * Resolve the principal for a request. Throws if no valid session. + * Results are cached per-request so multiple calls in the same + * loader don't hit the DB repeatedly. + */ + require(request: Request): Promise { + const cached = this.requestCache.get(request); + if (cached) { + return cached; + } + + const promise = this.resolve(request); + this.requestCache.set(request, promise); + return promise; + } + + private async resolve(request: Request): Promise { + const payload = await this.decodeCookie(request); + + const [session] = await this.opts.db + .select() + .from(authSessions) + .where(eq(authSessions.id, payload.sid)) + .limit(1); + + if (!session) { + throw new Error("Session not found"); + } + + if (session.expires_at < new Date()) { + await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id)); + throw new Error("Session expired"); + } + + if (session.kind === "api_key") { + if (!payload.api_key) { + throw new Error("API key session missing credential"); + } + + return { + kind: "api_key", + sessionId: session.id, + displayName: session.api_key_display ?? "API Key", + apiKey: payload.api_key, + }; + } + + if (!session.user_id) { + throw new Error("OIDC session missing user_id"); + } + + const [user] = await this.opts.db + .select() + .from(users) + .where(eq(users.id, session.user_id)) + .limit(1); + + if (!user) { + throw new Error("User record not found"); + } + + const role = (user.role in Roles ? user.role : "member") as Role; + return { + kind: "oidc", + sessionId: session.id, + user: { + id: user.id, + subject: user.sub, + role, + headscaleUserId: user.headscale_user_id ?? undefined, + onboarded: user.onboarded, + }, + profile: payload.profile ?? { + name: user.sub, + }, + }; + } + + // ── Authorization ────────────────────────────────────────────── + + /** + * Check if a principal has a given set of capabilities. + * API key principals always have full access. + */ + can(principal: Principal, capabilities: Capabilities): boolean { + if (principal.kind === "api_key") { + return true; + } + + const roleCaps = Roles[principal.user.role]; + return (capabilities & roleCaps) === capabilities; + } + + /** + * Check if a principal can act on a machine. Owners of the machine + * can act on it even without write_machines capability. + */ + canManageNode(principal: Principal, node: Machine): boolean { + if (principal.kind === "api_key") { + return true; + } + + const caps = Roles[principal.user.role]; + if ((caps & Capabilities.write_machines) !== 0) { + return true; + } + + const hsUserId = principal.user.headscaleUserId; + return hsUserId !== undefined && node.user?.id === hsUserId; + } + + // ── Session management ───────────────────────────────────────── + + /** + * Create a new OIDC session. Returns the Set-Cookie header value. + */ + async createOidcSession( + userId: string, + profile: NonNullable, + maxAge = this.opts.cookie.maxAge, + ): Promise { + const sid = ulid(); + await this.opts.db.insert(authSessions).values({ + id: sid, + kind: "oidc", + user_id: userId, + expires_at: new Date(Date.now() + maxAge * 1000), + }); + + return this.encodeCookie({ sid, profile }, maxAge); + } + + /** + * Create a new API key session. The API key is stored server-side + * as a SHA-256 hash — it never appears in the cookie. + * Returns the Set-Cookie header value. + */ + async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise { + const sid = ulid(); + await this.opts.db.insert(authSessions).values({ + id: sid, + kind: "api_key", + api_key_hash: this.hashApiKey(apiKey), + api_key_display: displayName, + expires_at: new Date(Date.now() + maxAge), + }); + + return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000)); + } + + /** + * Get the Headscale API key for making API calls. + * OIDC sessions use the configured oidc.headscale_api_key. + * API key sessions use the user-provided key stored in the cookie. + */ + getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string { + if (principal.kind === "api_key") { + return principal.apiKey; + } + + if (!oidcApiKey) { + throw new Error("OIDC sessions require oidc.headscale_api_key"); + } + + return oidcApiKey; + } + + /** + * Destroy the current session. Returns the Set-Cookie header that + * clears the cookie. + */ + async destroySession(request?: Request): Promise { + if (request) { + try { + const payload = await this.decodeCookie(request); + await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid)); + } catch { + // Cookie already invalid, just clear it + } + } + + const cookie = createCookie(this.opts.cookie.name, { + ...this.opts.cookie, + path: __PREFIX__, + }); + + return cookie.serialize("", { expires: new Date(0) }); + } + + // ── User management ──────────────────────────────────────────── + + /** + * Find or create a Headplane user by OIDC subject. Returns the + * user ID. Used during OIDC callback to establish identity. + */ + async findOrCreateUser(subject: string, defaultRole: Role): Promise { + const [existing] = await this.opts.db + .select() + .from(users) + .where(eq(users.sub, subject)) + .limit(1); + + if (existing) { + await this.opts.db + .update(users) + .set({ last_login_at: new Date(), updated_at: new Date() }) + .where(eq(users.id, existing.id)); + return existing.id; + } + + const id = ulid(); + await this.opts.db.insert(users).values({ + id, + sub: subject, + role: defaultRole, + caps: capsForRole(defaultRole), + onboarded: false, + }); + + return id; + } + + /** + * Check if there are any users in the database (for bootstrap). + */ + async hasAnyUsers(): Promise { + const [row] = await this.opts.db.select({ id: users.id }).from(users).limit(1); + return row !== undefined; + } + + /** + * Update the Headscale user link for a Headplane user. + */ + async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise { + await this.opts.db + .update(users) + .set({ headscale_user_id: headscaleUserId, updated_at: new Date() }) + .where(eq(users.id, userId)); + } + + /** + * Get the role for a given OIDC subject. Used by the users overview + * to display roles for Headscale users. + */ + async roleForSubject(subject: string): Promise { + const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1); + + if (!user) { + return; + } + + return (user.role in Roles ? user.role : "member") as Role; + } + + /** + * Reassign the role of a user identified by their OIDC subject. + * Cannot reassign the owner role. + */ + async reassignSubject(subject: string, role: Role): Promise { + const currentRole = await this.roleForSubject(subject); + if (currentRole === "owner") { + return false; + } + + await this.opts.db + .insert(users) + .values({ + id: ulid(), + sub: subject, + role, + caps: capsForRole(role), + onboarded: false, + }) + .onConflictDoUpdate({ + target: users.sub, + set: { role, caps: capsForRole(role), updated_at: new Date() }, + }); + + return true; + } + + /** + * Clean up expired sessions. Should be called periodically. + */ + async pruneExpiredSessions(): Promise { + await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date())); + } + + // ── Private helpers ──────────────────────────────────────────── + + private async encodeCookie(payload: CookiePayload, maxAge: number): Promise { + const cookie = createCookie(this.opts.cookie.name, { + ...this.opts.cookie, + path: __PREFIX__, + maxAge, + }); + + const signed = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const hmac = createHash("sha256") + .update(this.opts.secret + signed) + .digest("base64url"); + + return cookie.serialize(`${signed}.${hmac}`); + } + + private async decodeCookie(request: Request): Promise { + const cookieHeader = request.headers.get("cookie"); + if (!cookieHeader) { + throw new Error("No session cookie found"); + } + + const cookie = createCookie(this.opts.cookie.name, { + ...this.opts.cookie, + path: __PREFIX__, + }); + + const raw = (await cookie.parse(cookieHeader)) as string | null; + if (!raw) { + throw new Error("Session cookie is empty"); + } + + const dotIndex = raw.lastIndexOf("."); + if (dotIndex === -1) { + throw new Error("Malformed session cookie"); + } + + const signed = raw.slice(0, dotIndex); + const hmac = raw.slice(dotIndex + 1); + + const expected = createHash("sha256") + .update(this.opts.secret + signed) + .digest("base64url"); + + if (hmac !== expected) { + throw new Error("Invalid session cookie signature"); + } + + return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload; + } + + private hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); + } +} + +export function createAuthService(opts: AuthServiceOptions): AuthService { + return new AuthService(opts); +} diff --git a/app/server/web/headscale-identity.ts b/app/server/web/headscale-identity.ts new file mode 100644 index 0000000..ef72ff4 --- /dev/null +++ b/app/server/web/headscale-identity.ts @@ -0,0 +1,39 @@ +import type { User } from "~/types/User"; + +/** + * Extracts the OIDC subject from a Headscale user's providerId. + * Headscale stores providerId as a URL where the last path segment + * is the subject (e.g. "https://idp.example.com/"). This is + * the ONLY place this parsing should occur — all other code should + * use the stable headscale_user_id link on the Headplane user record. + */ +export function getOidcSubject(user: User): string | undefined { + if (user.provider !== "oidc" || !user.providerId) { + return; + } + + return user.providerId.split("/").pop(); +} + +/** + * Finds the Headscale user matching the given OIDC identity. + * Tries subject match first (providerId last segment), then falls + * back to email match. The fallback is needed because some IDPs + * issue different subjects per client application. + */ +export function findHeadscaleUserBySubject( + users: User[], + subject: string, + email?: string, +): User | undefined { + const bySubject = users.find((u) => getOidcSubject(u) === subject); + if (bySubject) { + return bySubject; + } + + if (!email) { + return; + } + + return users.find((u) => u.email === email); +} diff --git a/app/server/web/roles.ts b/app/server/web/roles.ts index 3454814..02d6a53 100644 --- a/app/server/web/roles.ts +++ b/app/server/web/roles.ts @@ -1,60 +1,24 @@ export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities]; export const Capabilities = { - // Can access the admin console ui_access: 1 << 0, - - // Read tailnet policy file (unimplemented) read_policy: 1 << 1, - - // Write tailnet policy file (unimplemented) write_policy: 1 << 2, - - // Read network configurations read_network: 1 << 3, - - // Write network configurations, for example, enable MagicDNS, split DNS, - // make subnet, or allow a node to be an exit node, enable HTTPS write_network: 1 << 4, - - // Read feature configuration (unimplemented) read_feature: 1 << 5, - - // Write feature configuration, for example, enable Taildrop (unimplemented) write_feature: 1 << 6, - - // Configure user & group provisioning configure_iam: 1 << 7, - - // Read machines, for example, see machine names and status read_machines: 1 << 8, - - // Write machines, for example, approve, rename, and remove machines write_machines: 1 << 9, - - // Read users and user roles read_users: 1 << 10, - - // Write users and user roles, for example, remove users, - // approve users, make Admin write_users: 1 << 11, - - // Can generate authkeys for any user generate_authkeys: 1 << 12, - - // Can generate authkeys for own user only generate_own_authkeys: 1 << 16, - - // Can use any tag (without being tag owner) (unimplemented) use_tags: 1 << 13, - - // Write tailnet name (unimplemented) write_tailnet: 1 << 14, - - // Owner flag owner: 1 << 15, } as const; -export type Roles = [keyof typeof Roles]; export const Roles = { owner: Capabilities.ui_access | @@ -126,12 +90,19 @@ export const Roles = { Capabilities.read_users | Capabilities.generate_own_authkeys, - // Default role for new users with 0 capabilities on the UI side of things + viewer: + Capabilities.ui_access | + Capabilities.read_machines | + Capabilities.read_users | + Capabilities.generate_own_authkeys, + + // No access — user exists but has not been granted any role member: 0, } as const; export type Role = keyof typeof Roles; export type Capability = keyof typeof Capabilities; + export function hasCapability(role: Role, capability: Capability): boolean { return (Roles[role] & Capabilities[capability]) !== 0; } @@ -146,3 +117,7 @@ export function getRoleFromCapabilities(capabilities: Capabilities): Role { return "member"; } + +export function capsForRole(role: Role): number { + return Roles[role]; +} diff --git a/app/server/web/sessions.ts b/app/server/web/sessions.ts deleted file mode 100644 index 4dbc2b7..0000000 --- a/app/server/web/sessions.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { eq } from "drizzle-orm"; -import { LibSQLDatabase } from "drizzle-orm/libsql/driver"; -import { EncryptJWT, jwtDecrypt } from "jose"; -import { createHash } from "node:crypto"; -import { open, readFile, rm } from "node:fs/promises"; -import { resolve } from "node:path"; -import { createCookie } from "react-router"; -import { ulid } from "ulidx"; - -import log from "~/utils/log"; - -import { users } from "../db/schema"; -import { Capabilities, Roles } from "./roles"; - -export interface AuthSession { - state: "auth"; - api_key: string; - user: { - subject: string; - name: string; - email?: string; - username?: string; - picture?: string; - }; -} - -interface JWTSession { - api_key: string; - user: { - subject: string; - name: string; - email?: string; - username?: string; - picture?: string; - }; -} - -export interface OidcFlowSession { - state: "flow"; - oidc: { - state: string; - nonce: string; - code_verifier: string; - redirect_uri: string; - }; -} - -interface AuthSessionOptions { - secret: string; - db: LibSQLDatabase; - oidcUsersFile?: string; - cookie: { - name: string; - secure: boolean; - maxAge: number; - domain?: string; - }; -} - -class Sessionizer { - private options: AuthSessionOptions; - - constructor(options: AuthSessionOptions) { - this.options = options; - } - - // This throws on the assumption that auth is already checked correctly - // on something that wraps the route calling auth. The top-level routes - // that call this are wrapped with try/catch to handle the error. - async auth(request: Request) { - return decodeSession(request, this.options); - } - - async createSession(payload: JWTSession, maxAge = this.options.cookie.maxAge) { - // TODO: What the hell is this garbage - return createSession(payload, { - ...this.options, - cookie: { - ...this.options.cookie, - maxAge, - }, - }); - } - - async destroySession() { - return destroySession(this.options); - } - - async roleForSubject(subject: string): Promise { - const [user] = await this.options.db - .select() - .from(users) - .where(eq(users.sub, subject)) - .limit(1); - - if (!user) { - return; - } - - // We need this in string form based on Object.keys of the roles - for (const [key, value] of Object.entries(Roles)) { - if (value === user.caps) { - return key as keyof typeof Roles; - } - } - } - - // Given an OR of capabilities, check if the session has the required - // capabilities. If not, return false. Can throw since it calls auth() - async check(request: Request, capabilities: Capabilities) { - const session = await this.auth(request); - - // This is the subject we set on API key based sessions. API keys - // inherently imply admin access so we return true for all checks. - if (session.user.subject === "unknown-non-oauth") { - return true; - } - - const [user] = await this.options.db - .select() - .from(users) - .where(eq(users.sub, session.user.subject)) - .limit(1); - - if (!user) { - return false; - } - - return (capabilities & user.caps) === capabilities; - } - - // Updates the capabilities and roles of a subject - // Creates the user record if it doesn't exist yet - async reassignSubject(subject: string, role: keyof typeof Roles) { - // Check if we are owner - const subjectRole = await this.roleForSubject(subject); - if (subjectRole === "owner") { - return false; - } - - // Use upsert to handle users who exist in Headscale but haven't - // logged into Headplane yet (no DB record) - await this.options.db - .insert(users) - .values({ - id: ulid(), - sub: subject, - caps: Roles[role], - onboarded: false, - }) - .onConflictDoUpdate({ - target: users.sub, - set: { caps: Roles[role] }, - }); - - return true; - } -} - -async function createSession(payload: JWTSession, options: AuthSessionOptions) { - const now = Math.floor(Date.now() / 1000); - const secret = createHash("sha256").update(options.secret, "utf8").digest(); - const jwt = await new EncryptJWT({ - ...payload, - }) - .setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT" }) - .setIssuedAt() - .setExpirationTime(now + options.cookie.maxAge) - .setIssuer("urn:tale:headplane") - .setAudience("urn:tale:headplane") - .setJti(ulid()) - .encrypt(secret); - - const cookie = createCookie(options.cookie.name, { - ...options.cookie, - path: __PREFIX__, - }); - - return cookie.serialize(jwt); -} - -async function decodeSession(request: Request, options: AuthSessionOptions) { - const cookieHeader = request.headers.get("cookie"); - if (cookieHeader === null) { - throw new Error("No session cookie found"); - } - - const cookie = createCookie(options.cookie.name, { - ...options.cookie, - path: __PREFIX__, - }); - - const cookieValue = (await cookie.parse(cookieHeader)) as string | null; - if (cookieValue === null) { - throw new Error("Session cookie is empty"); - } - - const secret = createHash("sha256").update(options.secret, "utf8").digest(); - const { payload } = await jwtDecrypt(cookieValue, secret, { - issuer: "urn:tale:headplane", - audience: "urn:tale:headplane", - }); - - // Safe since we encode the session directly into the JWT - return payload as unknown as JWTSession; -} - -async function destroySession(options: AuthSessionOptions) { - const cookie = createCookie(options.cookie.name, { - ...options.cookie, - path: __PREFIX__, - }); - - return cookie.serialize("", { - expires: new Date(0), - }); -} - -export async function createSessionStorage(options: AuthSessionOptions) { - if (options.oidcUsersFile) { - await migrateUserDatabase(options.oidcUsersFile, options.db); - } - - return new Sessionizer(options); -} - -async function migrateUserDatabase(path: string, db: LibSQLDatabase) { - const realPath = resolve(path); - - try { - const handle = await open(realPath, "a+"); - await handle.close(); - } catch (error) { - if (error != null && typeof error === "object" && "code" in error && error.code === "ENOENT") { - log.debug("config", "No old user database file found at %s", realPath); - return; - } - - log.warn("config", "Failed to migrate old user database at %s", realPath); - log.warn("config", "This is not an error, but existing users will not be migrated"); - log.warn("config", "Unable to open user database file: %s", String(error)); - log.debug("config", "Error details: %s", error); - return; - } - - log.info("config", "Found old user database file at %s", realPath); - log.info("config", "Migrating user database to the new SQL database"); - - let migratableUsers: { - u: string; - c: number; - oo?: boolean; - }[]; - - try { - const data = await readFile(realPath, "utf8"); - if (data.trim().length === 0) { - log.info("config", "Old user database file is empty, nothing to migrate"); - log.info("config", "You SHOULD remove oidc.user_storage_file from your config!"); - await rm(realPath, { force: true }); - return; - } - - const users = JSON.parse(data.trim()) as { - u?: string; - c?: number; - oo?: boolean; - }[]; - - migratableUsers = users.filter((user) => user.u !== undefined && user.c !== undefined) as { - u: string; - c: number; - oo?: boolean; - }[]; - } catch (error) { - log.warn("config", "Error reading old user database file: %s", error); - log.warn("config", "Not migrating any users"); - return; - } - - if (migratableUsers.length === 0) { - log.info("config", "No users found in the old database to migrate"); - return; - } - - log.info("config", "Migrating %d users from the old database", migratableUsers.length); - - const updated = await db - .insert(users) - .values( - migratableUsers.map((user) => ({ - id: ulid(), - sub: user.u, - caps: user.c, - onboarded: user.oo ?? false, - })), - ) - .onConflictDoNothing({ - target: users.sub, - }) - .returning(); - - log.info("config", "Migrated %d users successfully", updated.length); - log.info("config", "Removed old user database file %s", realPath); - await rm(realPath, { force: true }); -} diff --git a/docs/features/sso.md b/docs/features/sso.md index 67cac9a..fd7740f 100644 --- a/docs/features/sso.md +++ b/docs/features/sso.md @@ -13,58 +13,45 @@ outline: [2, 3] Single Sign-On allows users to authenticate with Headplane through an external -Identity Provider (IdP). It does this using the OpenID Connect (OIDC) protocol, -which is widely supported by many popular IdPs. +Identity Provider (IdP) using the OpenID Connect (OIDC) protocol. When enabled, +users sign in through your IdP and Headplane automatically links them to their +Headscale identity, assigns a role, and manages their session. ## Getting Started -To set up Single Sign-On (SSO) with Headplane, there are several steps involved. -As a general recommendation, please read through the entire guide before -beginning the process as there are several important factors to consider. ### Requirements -::: warning -If you are also using OpenID Connect (OIDC) authentication with Headscale, it is -**fundamentally important** that both Headscale and Headplane are configured to -use the *exact same client* in your Identity Provider (IdP). This means that -both services should share the same client ID and secret. +You'll need the following before proceeding: -This is necessary because Headplane relies on the user IDs provided by the IdP -to match users with their equivalent Headscale users. If Headscale and Headplane -are using different clients, the user IDs may not match up correctly, preventing -a user from viewing their devices in Headplane. -::: - -You'll need the following things set up before proceeding: - A working Headplane installation that is already configured. - An Identity Provider (IdP) that supports OAuth2 and OpenID Connect (OIDC). - `server.base_url` set to the public URL of your Headplane instance in your -configuration file (ie. the domain that's visible in the browser). + configuration file (the domain visible in the browser). - A Headscale API key with a relatively long expiration time (eg. 1 year). ### Configuring the Client -You'll need to create a client in your Identity Provider (IdP) that Headplane -can use for authentication. A part of that step involves giving an allowed -"redirect URL" to your IdP. This URL is where the IdP will send users back to -after they have authenticated. -For Headplane, the redirect URL will be in the following format, where the -domain is replaced with the value set for `server.base_url` in your Headplane -configuration: +You'll need to create a client in your Identity Provider that Headplane can use +for authentication. As part of that step, you'll need to register a "redirect +URL" — this is where the IdP sends users after they authenticate. + +For Headplane, the redirect URL will be in the following format (replace the +domain with the value set for `server.base_url`): ``` https://headplane.example.com/admin/oidc/callback ``` -Once you have created the client in your IdP, make note of the following -information as you'll need it for the Headplane configuration: +Once you have created the client, make note of the following: + - Client ID - Client Secret (if applicable) - Issuer URL ### OIDC Configuration -To enable OIDC authentication in Headplane, you'll need to add the necessary -configuration options via the file or environment variables. See below: + +To enable OIDC authentication in Headplane, add the following to your +configuration file: ```yaml oidc: @@ -75,18 +62,18 @@ oidc: # You can also provide the client secret via a file: # client_secret_path: "${HOME}/secrets/headplane_oidc_client_secret.txt" - # Those options should generally be sufficient, but you can also set these: + # These are usually auto-discovered, but can be set manually: # authorization_endpoint: "" # token_endpoint: "" # userinfo_endpoint: "" # scope: "openid email profile" # extra_params: # foo: "bar" - # baz: "qux" ``` -Headplane automatically tries to discover the necessary OIDC endpoints but if -your IdP does not support discovery, you may need to manually specify them. +Headplane automatically discovers OIDC endpoints from your issuer's +`/.well-known/openid-configuration`. If your IdP does not support discovery, +you'll need to set the endpoints manually. ### PKCE @@ -96,29 +83,147 @@ You may need to ensure that your Identity Provider is configured to accept this method. ::: -By default, Headplane does not use PKCE (Proof Key for Code Exchange) when -communicating with the Identity Provider. PKCE is generally a best practice for -OIDC and can enhance security. *Some Identity Providers may even require PKCE -to be used.* To enable PKCE you'll need to set `oidc.use_pkce` -to `true` in your Headplane configuration file: +By default, Headplane does not use PKCE (Proof Key for Code Exchange). PKCE is +a best practice for OIDC and enhances security — some IdPs even require it. To +enable PKCE: ```yaml oidc: use_pkce: true ``` -## Troubleshooting -Some of the common issues you may encounter when configuring OIDC with Headplane -include: +## How User Matching Works -- **Invalid API Key**: Ensure that the API key provided to Headplane is valid -and has not expired. -- **Missing [some]_endpoint**: If your IdP does not provide standard OIDC -endpoints, you may need to manually specify them in the Headplane configuration. -- **Missing the `sub` claim**: Ensure that your IdP is configured to include the -`sub` claim in the ID token, as this is required for Headplane to identify users. -- **Redirect URI Mismatch**: Ensure that the redirect URI configured in your IdP -and that `server.base_url` in Headplane match exactly. -- **Cookie Issues**: The OIDC authentication relies on your cookie configuration -for Headplane. If OIDC cannot complete due to a missing session or invalid -session then please check your cookie settings. +When a user signs in via OIDC, Headplane needs to link them to their +corresponding Headscale user. This is important for features like showing a +user's own machines, self-service pre-auth keys, and WebSSH. + +### Matching Strategy + +Headplane uses a two-step matching strategy: + +1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for + each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane + extracts the last path segment and compares it to the `sub` claim from the + OIDC token. If they match, the user is linked. + +2. **Email match (fallback)**: If the subject doesn't match, Headplane falls + back to comparing the user's email address from the OIDC `userinfo` endpoint + against the email stored on the Headscale user record. + +Once a link is established, it's stored as a `headscale_user_id` in Headplane's +database and reused on subsequent logins — so the matching only needs to succeed +once. + +### Headscale Without OIDC + +If your Headscale instance uses **local users** (created via +`headscale users create`) rather than OIDC, automatic matching cannot work — +local users have no `provider_id` or email to compare against. + +In this case, Headplane will prompt the user during onboarding to manually +select which Headscale user they are. This selection is persisted, so it only +needs to happen once. After linking, all ownership-based features (viewing your +own machines, self-service pre-auth keys, WebSSH) work normally. + +::: tip +If you skip the user selection during onboarding, you can still use Headplane +— you just won't have ownership-based features. An admin can manage everything +regardless of whether users are linked. +::: + +### Same Client vs. Different Clients + +::: tip Recommended +Using the **same OIDC client** for both Headscale and Headplane is the simplest +and most reliable setup. The `sub` claim will be identical for both services, +so subject matching always works. +::: + +If your Headscale and Headplane use **different OIDC clients**, some Identity +Providers (notably Azure AD / Entra ID) may issue different `sub` values per +client application. In this case: + +- Subject matching will fail on the first login. +- Headplane will fall back to email matching, which requires that the `email` + claim is available from both your IdP's `userinfo` endpoint and Headscale's + user record. +- Once the link is established, subsequent logins will work regardless because + the link is persisted. + +::: warning +If you use different clients **and** your IdP does not provide an `email` claim, +Headplane will not be able to match users to their Headscale identity. Users +will still be able to sign in, but they won't be linked to a Headscale user — +meaning features like viewing their own machines or self-service pre-auth keys +won't work. +::: + +## Roles and Permissions + +When SSO is enabled, Headplane uses a role-based access control system to +determine what each user can do in the UI. + +### Available Roles + +| Role | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------ | +| **Owner** | Full access to everything. Cannot be reassigned. Automatically granted to the first user who signs in. | +| **Admin** | Full access except the owner-specific flag. Can manage all users, machines, ACLs, DNS, and settings. | +| **Network Admin** | Can manage ACLs, DNS, and network settings. Can view machines and users. Can generate pre-auth keys. | +| **IT Admin** | Can manage machines, users, and feature settings. Can configure IAM. Cannot modify ACLs or DNS. | +| **Auditor** | Read-only access to everything. Can generate their own pre-auth keys. | +| **Viewer** | Can view machines and users. Can generate their own pre-auth keys. | +| **Member** | No UI access. The user exists in Headplane's database but has not been granted any permissions. | + +### First Login (Owner Bootstrap) + +The very first user to sign in via OIDC is automatically assigned the **Owner** +role. All subsequent users are assigned the **Member** role (no access) by +default. An owner or admin must then assign them an appropriate role through +the Users page. + +### API Key Sessions + +Users who sign in with a Headscale API key (instead of OIDC) are treated as +having full access. API key sessions bypass the role system entirely since +possession of the API key already implies administrative access to Headscale. + +### Onboarding + +When a new OIDC user signs in for the first time, they go through a brief +onboarding flow that helps them connect their first device to the Tailnet. This +flow can be skipped. Once completed, users are taken to the main dashboard. + +## Troubleshooting + +### Common Issues + +- **"OIDC is not enabled or misconfigured"**: Check that your `oidc` section + is present in the config and that the issuer URL is reachable from the + Headplane server. + +- **User signs in but can't see their machines**: The user's Headscale identity + wasn't matched. Check that either the `sub` claim matches or the `email` + claim is available (see [How User Matching Works](#how-user-matching-works)). + +- **"Session cookie is empty" or login loop**: Check your `cookie_secure` + setting. If Headplane is behind a reverse proxy with HTTPS, set it to `true`. + If running without HTTPS (eg. local development), set it to `false`. + +- **Invalid API Key**: The `oidc.headscale_api_key` may have expired. Generate + a new one with `headscale apikeys create --expiration 999d`. + +- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the + ID token. This is required by the OIDC spec but some providers need explicit + configuration. + +- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP + matches `{server.base_url}/admin/oidc/callback` exactly. + +- **PKCE errors**: If your IdP requires PKCE, set `oidc.use_pkce: true`. If + you see errors mentioning `code_verifier`, this is almost always the cause. + +- **Missing endpoints**: If your IdP does not support OIDC discovery, you'll + need to set `authorization_endpoint`, `token_endpoint`, and + `userinfo_endpoint` manually in the config. diff --git a/drizzle/0003_thick_otto_octavius.sql b/drizzle/0003_thick_otto_octavius.sql new file mode 100644 index 0000000..f6118c0 --- /dev/null +++ b/drizzle/0003_thick_otto_octavius.sql @@ -0,0 +1,22 @@ +CREATE TABLE `auth_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `kind` text NOT NULL, + `user_id` text, + `api_key_hash` text, + `api_key_display` text, + `expires_at` integer NOT NULL, + `created_at` integer +); +--> statement-breakpoint +ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint +ALTER TABLE `users` ADD `created_at` integer;--> statement-breakpoint +ALTER TABLE `users` ADD `updated_at` integer;--> statement-breakpoint +ALTER TABLE `users` ADD `last_login_at` integer;--> statement-breakpoint + +-- Backfill role from caps for existing users +UPDATE `users` SET `role` = 'owner' WHERE `caps` = 65535;--> statement-breakpoint +UPDATE `users` SET `role` = 'admin' WHERE `caps` = 32767;--> statement-breakpoint +UPDATE `users` SET `role` = 'network_admin' WHERE `caps` = 30015;--> statement-breakpoint +UPDATE `users` SET `role` = 'it_admin' WHERE `caps` = 8171;--> statement-breakpoint +UPDATE `users` SET `role` = 'auditor' WHERE `caps` = 66859; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..c0c1835 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,214 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e397c1d9-19a4-494a-9b87-5a94a093286a", + "prevId": "2c18fbcb-d5f5-47c0-962d-54121cbb2e71", + "tables": { + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_hash": { + "name": "api_key_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_display": { + "name": "api_key_display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ephemeral_nodes": { + "name": "ephemeral_nodes", + "columns": { + "auth_key": { + "name": "auth_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "node_key": { + "name": "node_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_info": { + "name": "host_info", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "headscale_user_id": { + "name": "headscale_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarded": { + "name": "onboarded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caps": { + "name": "caps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "users_sub_unique": { + "name": "users_sub_unique", + "columns": ["sub"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 78491b6..c80130c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,27 +1,34 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1750355487927, - "tag": "0000_spicy_bloodscream", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1755554742267, - "tag": "0001_naive_lilith", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1755617607599, - "tag": "0002_square_bloodstorm", - "breakpoints": true - } - ] + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1750355487927, + "tag": "0000_spicy_bloodscream", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1755554742267, + "tag": "0001_naive_lilith", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1755617607599, + "tag": "0002_square_bloodstorm", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1772917638504, + "tag": "0003_thick_otto_octavius", + "breakpoints": true + } + ] }