feat: begin working on user auth

This commit is contained in:
Aarnav Tale
2025-03-29 14:12:15 -04:00
parent 8429b19c4a
commit bf02015dc7
11 changed files with 562 additions and 118 deletions
+1
View File
@@ -18,4 +18,5 @@ server
├── web/
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
│ ├── roles.ts: Contains information about authentication permissions.
│ ├── sessions.ts: Initializes the session store and methods to manage it.
+1
View File
@@ -27,6 +27,7 @@ const oidcConfig = type({
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
redirect_uri: 'string.url?',
user_storage_file: 'string = "/var/lib/headplane/users.json"',
disable_api_key_login: stringToBool,
headscale_api_key: 'string',
strict_validation: stringToBool.default(true),
+9 -6
View File
@@ -40,12 +40,15 @@ const appLoadContext = {
),
// TODO: Better cookie options in config
sessions: createSessionStorage({
name: '_hp_session',
maxAge: 60 * 60 * 24, // 24 hours
secure: config.server.cookie_secure,
secrets: [config.server.cookie_secret],
}),
sessions: await createSessionStorage(
{
name: '_hp_session',
maxAge: 60 * 60 * 24, // 24 hours
secure: config.server.cookie_secure,
secrets: [config.server.cookie_secret],
},
config.oidc?.user_storage_file,
),
client: await createApiClient(
config.headscale.url,
+144
View File
@@ -0,0 +1,144 @@
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
export const Capabilities = {
// Can access the admin console
ui_access: 1 << 0,
// Read tailnet policy file
read_policy: 1 << 1,
// Write tailnet policy file
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
read_feature: 1 << 5,
// Write feature configuration, for example, enable Taildrop
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
generate_authkeys: 1 << 12,
// Can use any tag (without being tag owner)
use_tags: 1 << 13,
// Write tailnet name
write_tailnet: 1 << 14,
// Owner flag
owner: 1 << 15,
} as const;
export type Roles = [keyof typeof Roles];
export const Roles = {
owner:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet |
Capabilities.owner,
admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
network_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
it_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys,
auditor:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users,
// Default role for new users with 0 capabilities on the UI side of things
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;
}
export function getRoleFromCapabilities(capabilities: Capabilities): Role {
const iterable = Roles as Record<string, Capabilities>;
for (const role in iterable) {
if (iterable[role] === capabilities) {
return role as Role;
}
}
return 'member';
}
+137 -3
View File
@@ -1,9 +1,13 @@
import { open, readFile } from 'node:fs/promises';
import { exit } from 'node:process';
import {
CookieSerializeOptions,
Session,
SessionStorage,
createCookieSessionStorage,
} from 'react-router';
import log from '~/utils/log';
import { Capabilities, Roles } from './roles';
export interface AuthSession {
state: 'auth';
@@ -42,7 +46,16 @@ interface CookieOptions {
class Sessionizer {
private storage: SessionStorage<JoinedSession, Error>;
constructor(options: CookieOptions) {
private caps: Record<string, Capabilities>;
private capsPath?: string;
constructor(
options: CookieOptions,
caps: Record<string, Capabilities>,
capsPath?: string,
) {
this.caps = caps;
this.capsPath = capsPath;
this.storage = createCookieSessionStorage({
cookie: {
...options,
@@ -71,6 +84,84 @@ class Sessionizer {
return session as Session<AuthSession, Error>;
}
roleForSubject(subject: string) {
const role = this.caps[subject];
// We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) {
if (value === role) {
return key;
}
}
}
// 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);
const { subject } = session.get('user') ?? {};
if (!subject) {
return false;
}
// 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 (subject === 'unknown-non-oauth') {
return true;
}
// If the role does not exist, then this is a new subject that we have
// not seen before. Since this is new, we set access to the lowest
// level by default which is the member role.
//
// This also allows us to avoid configuring preventing sign ups with
// OIDC, since the default sign up logic gives member which does not
// have access to the UI whatsoever.
const role = this.caps[subject];
if (!role) {
const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole) === capabilities;
}
return (capabilities & role) === capabilities;
}
// This code is very simple, if the user does not exist in the database
// file then we register it with the lowest level of access. If the user
// database is empty, the first user to sign in will be given the owner
// role.
private async registerSubject(subject: string) {
if (this.caps[subject]) {
return this.caps[subject];
}
if (Object.keys(this.caps).length === 0) {
log.debug('auth', 'First user registered as owner: %s', subject);
this.caps[subject] = Roles.owner;
await this.flushUserDatabase();
return this.caps[subject];
}
log.debug('auth', 'New user registered as member: %s', subject);
this.caps[subject] = Roles.member;
await this.flushUserDatabase();
return this.caps[subject];
}
private async flushUserDatabase() {
if (!this.capsPath) {
return;
}
const data = Object.entries(this.caps).map(([u, c]) => ({ u, c }));
try {
const handle = await open(this.capsPath, 'w');
await handle.write(JSON.stringify(data));
await handle.close();
} catch (error) {
log.error('config', 'Error writing user database file: %s', error);
}
}
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
return this.storage.getSession(request.headers.get('cookie')) as Promise<
Session<T, Error>
@@ -86,6 +177,49 @@ class Sessionizer {
}
}
export function createSessionStorage(options: CookieOptions) {
return new Sessionizer(options);
export async function createSessionStorage(
options: CookieOptions,
usersPath?: string,
) {
const map: Record<string, Capabilities> = {};
if (usersPath) {
// We need to load our users from the file (default to empty map)
// We then translate each user into a capability object using the helper
// method defined in the roles.ts file
const data = await loadUserFile(usersPath);
log.debug('config', 'Loaded %d users from database', data.length);
for (const user of data) {
map[user.u] = user.c;
}
}
return new Sessionizer(options, map, usersPath);
}
async function loadUserFile(path: string) {
try {
const handle = await open(path, 'w');
log.info('config', 'Using user database file at %s', path);
await handle.close();
} catch (error) {
log.info('config', 'User database file not accessible at %s', path);
log.debug('config', 'Error details: %s', error);
exit(1);
}
try {
const data = await readFile(path, 'utf8');
const users = JSON.parse(data) as { u?: string; c?: number }[];
// Never trust user input
return users.filter((user) => user.u && user.c) as {
u: string;
c: number;
}[];
} catch (error) {
log.debug('config', 'Error reading user database file: %s', error);
log.debug('config', 'Using empty user database');
return [];
}
}