mirror of
https://github.com/tale/headplane.git
synced 2026-08-09 05:39:22 +00:00
feat: begin working on user auth
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user