fix role assignment for users without headplane db record

This commit is contained in:
drifterza
2026-02-24 17:37:09 +02:00
parent 5e45295523
commit 2e6b387d67
+242 -253
View File
@@ -1,317 +1,306 @@
import { createHash } from 'node:crypto'; import { eq } from "drizzle-orm";
import { open, readFile, rm } from 'node:fs/promises'; import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
import { resolve } from 'node:path'; import { EncryptJWT, jwtDecrypt } from "jose";
import { eq } from 'drizzle-orm'; import { createHash } from "node:crypto";
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver'; import { open, readFile, rm } from "node:fs/promises";
import { EncryptJWT, jwtDecrypt } from 'jose'; import { resolve } from "node:path";
import { createCookie } from 'react-router'; import { createCookie } from "react-router";
import { ulid } from 'ulidx'; import { ulid } from "ulidx";
import log from '~/utils/log';
import { users } from '../db/schema'; import log from "~/utils/log";
import { Capabilities, Roles } from './roles';
import { users } from "../db/schema";
import { Capabilities, Roles } from "./roles";
export interface AuthSession { export interface AuthSession {
state: 'auth'; state: "auth";
api_key: string; api_key: string;
user: { user: {
subject: string; subject: string;
name: string; name: string;
email?: string; email?: string;
username?: string; username?: string;
picture?: string; picture?: string;
}; };
} }
interface JWTSession { interface JWTSession {
api_key: string; api_key: string;
user: { user: {
subject: string; subject: string;
name: string; name: string;
email?: string; email?: string;
username?: string; username?: string;
picture?: string; picture?: string;
}; };
} }
export interface OidcFlowSession { export interface OidcFlowSession {
state: 'flow'; state: "flow";
oidc: { oidc: {
state: string; state: string;
nonce: string; nonce: string;
code_verifier: string; code_verifier: string;
redirect_uri: string; redirect_uri: string;
}; };
} }
interface AuthSessionOptions { interface AuthSessionOptions {
secret: string; secret: string;
db: LibSQLDatabase; db: LibSQLDatabase;
oidcUsersFile?: string; oidcUsersFile?: string;
cookie: { cookie: {
name: string; name: string;
secure: boolean; secure: boolean;
maxAge: number; maxAge: number;
domain?: string; domain?: string;
}; };
} }
class Sessionizer { class Sessionizer {
private options: AuthSessionOptions; private options: AuthSessionOptions;
constructor(options: AuthSessionOptions) { constructor(options: AuthSessionOptions) {
this.options = options; this.options = options;
} }
// This throws on the assumption that auth is already checked correctly // This throws on the assumption that auth is already checked correctly
// on something that wraps the route calling auth. The top-level routes // on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error. // that call this are wrapped with try/catch to handle the error.
async auth(request: Request) { async auth(request: Request) {
return decodeSession(request, this.options); return decodeSession(request, this.options);
} }
async createSession( async createSession(payload: JWTSession, maxAge = this.options.cookie.maxAge) {
payload: JWTSession, // TODO: What the hell is this garbage
maxAge = this.options.cookie.maxAge, return createSession(payload, {
) { ...this.options,
// TODO: What the hell is this garbage cookie: {
return createSession(payload, { ...this.options.cookie,
...this.options, maxAge,
cookie: { },
...this.options.cookie, });
maxAge, }
},
});
}
async destroySession() { async destroySession() {
return destroySession(this.options); return destroySession(this.options);
} }
async roleForSubject( async roleForSubject(subject: string): Promise<keyof typeof Roles | undefined> {
subject: string, const [user] = await this.options.db
): Promise<keyof typeof Roles | undefined> { .select()
const [user] = await this.options.db .from(users)
.select() .where(eq(users.sub, subject))
.from(users) .limit(1);
.where(eq(users.sub, subject))
.limit(1);
if (!user) { if (!user) {
return; return;
} }
// We need this in string form based on Object.keys of the roles // We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) { for (const [key, value] of Object.entries(Roles)) {
if (value === user.caps) { if (value === user.caps) {
return key as keyof typeof Roles; return key as keyof typeof Roles;
} }
} }
} }
// Given an OR of capabilities, check if the session has the required // Given an OR of capabilities, check if the session has the required
// capabilities. If not, return false. Can throw since it calls auth() // capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) { async check(request: Request, capabilities: Capabilities) {
const session = await this.auth(request); const session = await this.auth(request);
// This is the subject we set on API key based sessions. API keys // This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks. // inherently imply admin access so we return true for all checks.
if (session.user.subject === 'unknown-non-oauth') { if (session.user.subject === "unknown-non-oauth") {
return true; return true;
} }
const [user] = await this.options.db const [user] = await this.options.db
.select() .select()
.from(users) .from(users)
.where(eq(users.sub, session.user.subject)) .where(eq(users.sub, session.user.subject))
.limit(1); .limit(1);
if (!user) { if (!user) {
return false; return false;
} }
return (capabilities & user.caps) === capabilities; return (capabilities & user.caps) === capabilities;
} }
// Updates the capabilities and roles of a subject // Updates the capabilities and roles of a subject
async reassignSubject(subject: string, role: keyof typeof Roles) { // Creates the user record if it doesn't exist yet
// Check if we are owner async reassignSubject(subject: string, role: keyof typeof Roles) {
const subjectRole = await this.roleForSubject(subject); // Check if we are owner
if (subjectRole === 'owner') { const subjectRole = await this.roleForSubject(subject);
return false; if (subjectRole === "owner") {
} return false;
}
await this.options.db // Use upsert to handle users who exist in Headscale but haven't
.update(users) // logged into Headplane yet (no DB record)
.set({ await this.options.db
caps: Roles[role], .insert(users)
}) .values({
.where(eq(users.sub, subject)); id: ulid(),
sub: subject,
caps: Roles[role],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[role] },
});
return true; return true;
} }
} }
async function createSession(payload: JWTSession, options: AuthSessionOptions) { async function createSession(payload: JWTSession, options: AuthSessionOptions) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const secret = createHash('sha256').update(options.secret, 'utf8').digest(); const secret = createHash("sha256").update(options.secret, "utf8").digest();
const jwt = await new EncryptJWT({ const jwt = await new EncryptJWT({
...payload, ...payload,
}) })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' }) .setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT" })
.setIssuedAt() .setIssuedAt()
.setExpirationTime(now + options.cookie.maxAge) .setExpirationTime(now + options.cookie.maxAge)
.setIssuer('urn:tale:headplane') .setIssuer("urn:tale:headplane")
.setAudience('urn:tale:headplane') .setAudience("urn:tale:headplane")
.setJti(ulid()) .setJti(ulid())
.encrypt(secret); .encrypt(secret);
const cookie = createCookie(options.cookie.name, { const cookie = createCookie(options.cookie.name, {
...options.cookie, ...options.cookie,
path: __PREFIX__, path: __PREFIX__,
}); });
return cookie.serialize(jwt); return cookie.serialize(jwt);
} }
async function decodeSession(request: Request, options: AuthSessionOptions) { async function decodeSession(request: Request, options: AuthSessionOptions) {
const cookieHeader = request.headers.get('cookie'); const cookieHeader = request.headers.get("cookie");
if (cookieHeader === null) { if (cookieHeader === null) {
throw new Error('No session cookie found'); throw new Error("No session cookie found");
} }
const cookie = createCookie(options.cookie.name, { const cookie = createCookie(options.cookie.name, {
...options.cookie, ...options.cookie,
path: __PREFIX__, path: __PREFIX__,
}); });
const cookieValue = (await cookie.parse(cookieHeader)) as string | null; const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
if (cookieValue === null) { if (cookieValue === null) {
throw new Error('Session cookie is empty'); throw new Error("Session cookie is empty");
} }
const secret = createHash('sha256').update(options.secret, 'utf8').digest(); const secret = createHash("sha256").update(options.secret, "utf8").digest();
const { payload } = await jwtDecrypt(cookieValue, secret, { const { payload } = await jwtDecrypt(cookieValue, secret, {
issuer: 'urn:tale:headplane', issuer: "urn:tale:headplane",
audience: 'urn:tale:headplane', audience: "urn:tale:headplane",
}); });
// Safe since we encode the session directly into the JWT // Safe since we encode the session directly into the JWT
return payload as unknown as JWTSession; return payload as unknown as JWTSession;
} }
async function destroySession(options: AuthSessionOptions) { async function destroySession(options: AuthSessionOptions) {
const cookie = createCookie(options.cookie.name, { const cookie = createCookie(options.cookie.name, {
...options.cookie, ...options.cookie,
path: __PREFIX__, path: __PREFIX__,
}); });
return cookie.serialize('', { return cookie.serialize("", {
expires: new Date(0), expires: new Date(0),
}); });
} }
export async function createSessionStorage(options: AuthSessionOptions) { export async function createSessionStorage(options: AuthSessionOptions) {
if (options.oidcUsersFile) { if (options.oidcUsersFile) {
await migrateUserDatabase(options.oidcUsersFile, options.db); await migrateUserDatabase(options.oidcUsersFile, options.db);
} }
return new Sessionizer(options); return new Sessionizer(options);
} }
async function migrateUserDatabase(path: string, db: LibSQLDatabase) { async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
const realPath = resolve(path); const realPath = resolve(path);
try { try {
const handle = await open(realPath, 'a+'); const handle = await open(realPath, "a+");
await handle.close(); await handle.close();
} catch (error) { } catch (error) {
if ( if (error != null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
error != null && log.debug("config", "No old user database file found at %s", realPath);
typeof error === 'object' && return;
'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", "Failed to migrate old user database at %s", realPath);
log.warn( log.warn("config", "This is not an error, but existing users will not be migrated");
'config', log.warn("config", "Unable to open user database file: %s", String(error));
'This is not an error, but existing users will not be migrated', log.debug("config", "Error details: %s", error);
); return;
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", "Found old user database file at %s", realPath);
log.info('config', 'Migrating user database to the new SQL database'); log.info("config", "Migrating user database to the new SQL database");
let migratableUsers: { let migratableUsers: {
u: string; u: string;
c: number; c: number;
oo?: boolean; oo?: boolean;
}[]; }[];
try { try {
const data = await readFile(realPath, 'utf8'); const data = await readFile(realPath, "utf8");
if (data.trim().length === 0) { if (data.trim().length === 0) {
log.info('config', 'Old user database file is empty, nothing to migrate'); log.info("config", "Old user database file is empty, nothing to migrate");
log.info( log.info("config", "You SHOULD remove oidc.user_storage_file from your config!");
'config', await rm(realPath, { force: true });
'You SHOULD remove oidc.user_storage_file from your config!', return;
); }
await rm(realPath, { force: true });
return;
}
const users = JSON.parse(data.trim()) as { const users = JSON.parse(data.trim()) as {
u?: string; u?: string;
c?: number; c?: number;
oo?: boolean; oo?: boolean;
}[]; }[];
migratableUsers = users.filter( migratableUsers = users.filter((user) => user.u !== undefined && user.c !== undefined) as {
(user) => user.u !== undefined && user.c !== undefined, u: string;
) as { c: number;
u: string; oo?: boolean;
c: number; }[];
oo?: boolean; } catch (error) {
}[]; log.warn("config", "Error reading old user database file: %s", error);
} catch (error) { log.warn("config", "Not migrating any users");
log.warn('config', 'Error reading old user database file: %s', error); return;
log.warn('config', 'Not migrating any users'); }
return;
}
if (migratableUsers.length === 0) { if (migratableUsers.length === 0) {
log.info('config', 'No users found in the old database to migrate'); log.info("config", "No users found in the old database to migrate");
return; return;
} }
log.info( log.info("config", "Migrating %d users from the old database", migratableUsers.length);
'config',
'Migrating %d users from the old database',
migratableUsers.length,
);
const updated = await db const updated = await db
.insert(users) .insert(users)
.values( .values(
migratableUsers.map((user) => ({ migratableUsers.map((user) => ({
id: ulid(), id: ulid(),
sub: user.u, sub: user.u,
caps: user.c, caps: user.c,
onboarded: user.oo ?? false, onboarded: user.oo ?? false,
})), })),
) )
.onConflictDoNothing({ .onConflictDoNothing({
target: users.sub, target: users.sub,
}) })
.returning(); .returning();
log.info('config', 'Migrated %d users successfully', updated.length); log.info("config", "Migrated %d users successfully", updated.length);
log.info('config', 'Removed old user database file %s', realPath); log.info("config", "Removed old user database file %s", realPath);
await rm(realPath, { force: true }); await rm(realPath, { force: true });
} }