mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: add namespace-based multi-user isolation
Implement namespace support across sessions, machines, and users for multi-user server deployments. Add access control with specific error reasons (namespace-missing, access-denied, not-found) and database schema updates with namespace columns and indexes.
This commit is contained in:
@@ -3,6 +3,7 @@ import { SignJWT } from 'jose'
|
||||
import { z } from 'zod'
|
||||
import { configuration } from '../../configuration'
|
||||
import { safeCompareStrings } from '../../utils/crypto'
|
||||
import { parseAccessToken } from '../../utils/accessToken'
|
||||
import { validateTelegramInitData } from '../telegramInitData'
|
||||
import { getOrCreateOwnerId } from '../ownerId'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
@@ -32,14 +33,17 @@ export function createAuthRoutes(jwtSecret: Uint8Array, store: Store): Hono<WebA
|
||||
let username: string | undefined
|
||||
let firstName: string | undefined
|
||||
let lastName: string | undefined
|
||||
let namespace: string
|
||||
|
||||
// Access Token authentication (CLI_API_TOKEN)
|
||||
if ('accessToken' in parsed.data) {
|
||||
if (!safeCompareStrings(parsed.data.accessToken, configuration.cliApiToken)) {
|
||||
const parsedToken = parseAccessToken(parsed.data.accessToken)
|
||||
if (!parsedToken || !safeCompareStrings(parsedToken.baseToken, configuration.cliApiToken)) {
|
||||
return c.json({ error: 'Invalid access token' }, 401)
|
||||
}
|
||||
userId = await getOrCreateOwnerId()
|
||||
firstName = 'Web User'
|
||||
namespace = parsedToken.namespace
|
||||
} else {
|
||||
if (!configuration.telegramEnabled || !configuration.telegramBotToken) {
|
||||
return c.json({ error: 'Telegram authentication is disabled. Configure TELEGRAM_BOT_TOKEN.' }, 503)
|
||||
@@ -61,9 +65,10 @@ export function createAuthRoutes(jwtSecret: Uint8Array, store: Store): Hono<WebA
|
||||
username = result.user.username
|
||||
firstName = result.user.first_name
|
||||
lastName = result.user.last_name
|
||||
namespace = storedUser.namespace
|
||||
}
|
||||
|
||||
const token = await new SignJWT({ uid: userId })
|
||||
const token = await new SignJWT({ uid: userId, ns: namespace })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('15m')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SignJWT } from 'jose'
|
||||
import { z } from 'zod'
|
||||
import { configuration } from '../../configuration'
|
||||
import { safeCompareStrings } from '../../utils/crypto'
|
||||
import { parseAccessToken } from '../../utils/accessToken'
|
||||
import { validateTelegramInitData } from '../telegramInitData'
|
||||
import { getOrCreateOwnerId } from '../ownerId'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
@@ -23,9 +24,11 @@ export function createBindRoutes(jwtSecret: Uint8Array, store: Store): Hono<WebA
|
||||
return c.json({ error: 'Invalid body' }, 400)
|
||||
}
|
||||
|
||||
if (!safeCompareStrings(parsed.data.accessToken, configuration.cliApiToken)) {
|
||||
const parsedToken = parseAccessToken(parsed.data.accessToken)
|
||||
if (!parsedToken || !safeCompareStrings(parsedToken.baseToken, configuration.cliApiToken)) {
|
||||
return c.json({ error: 'Invalid access token' }, 401)
|
||||
}
|
||||
const namespace = parsedToken.namespace
|
||||
|
||||
if (!configuration.telegramEnabled || !configuration.telegramBotToken) {
|
||||
return c.json({ error: 'Telegram authentication is disabled. Configure TELEGRAM_BOT_TOKEN.' }, 503)
|
||||
@@ -37,11 +40,15 @@ export function createBindRoutes(jwtSecret: Uint8Array, store: Store): Hono<WebA
|
||||
}
|
||||
|
||||
const telegramUserId = String(result.user.id)
|
||||
store.addUser('telegram', telegramUserId)
|
||||
const existingUser = store.getUser('telegram', telegramUserId)
|
||||
if (existingUser && existingUser.namespace !== namespace) {
|
||||
return c.json({ error: 'already_bound' }, 409)
|
||||
}
|
||||
store.addUser('telegram', telegramUserId, namespace)
|
||||
|
||||
const userId = await getOrCreateOwnerId()
|
||||
|
||||
const token = await new SignJWT({ uid: userId })
|
||||
const token = await new SignJWT({ uid: userId, ns: namespace })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('15m')
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { configuration } from '../../configuration'
|
||||
import { safeCompareStrings } from '../../utils/crypto'
|
||||
import type { SyncEngine } from '../../sync/syncEngine'
|
||||
import { parseAccessToken } from '../../utils/accessToken'
|
||||
import type { Machine, Session, SyncEngine } from '../../sync/syncEngine'
|
||||
|
||||
const bearerSchema = z.string().regex(/^Bearer\s+(.+)$/i)
|
||||
|
||||
@@ -23,8 +24,44 @@ const getMessagesQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional()
|
||||
})
|
||||
|
||||
export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono {
|
||||
const app = new Hono()
|
||||
type CliEnv = {
|
||||
Variables: {
|
||||
namespace: string
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSessionForNamespace(
|
||||
engine: SyncEngine,
|
||||
sessionId: string,
|
||||
namespace: string
|
||||
): { ok: true; session: Session } | { ok: false; status: 403 | 404; error: string } {
|
||||
const session = engine.getSessionByNamespace(sessionId, namespace)
|
||||
if (session) {
|
||||
return { ok: true, session }
|
||||
}
|
||||
if (engine.getSession(sessionId)) {
|
||||
return { ok: false, status: 403, error: 'Session access denied' }
|
||||
}
|
||||
return { ok: false, status: 404, error: 'Session not found' }
|
||||
}
|
||||
|
||||
function resolveMachineForNamespace(
|
||||
engine: SyncEngine,
|
||||
machineId: string,
|
||||
namespace: string
|
||||
): { ok: true; machine: Machine } | { ok: false; status: 403 | 404; error: string } {
|
||||
const machine = engine.getMachineByNamespace(machineId, namespace)
|
||||
if (machine) {
|
||||
return { ok: true, machine }
|
||||
}
|
||||
if (engine.getMachine(machineId)) {
|
||||
return { ok: false, status: 403, error: 'Machine access denied' }
|
||||
}
|
||||
return { ok: false, status: 404, error: 'Machine not found' }
|
||||
}
|
||||
|
||||
export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<CliEnv> {
|
||||
const app = new Hono<CliEnv>()
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const raw = c.req.header('authorization')
|
||||
@@ -38,10 +75,12 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono {
|
||||
}
|
||||
|
||||
const token = parsed.data.replace(/^Bearer\s+/i, '')
|
||||
if (!safeCompareStrings(token, configuration.cliApiToken)) {
|
||||
const parsedToken = parseAccessToken(token)
|
||||
if (!parsedToken || !safeCompareStrings(parsedToken.baseToken, configuration.cliApiToken)) {
|
||||
return c.json({ error: 'Invalid token' }, 401)
|
||||
}
|
||||
|
||||
c.set('namespace', parsedToken.namespace)
|
||||
return await next()
|
||||
})
|
||||
|
||||
@@ -56,7 +95,8 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono {
|
||||
return c.json({ error: 'Invalid body' }, 400)
|
||||
}
|
||||
|
||||
const session = engine.getOrCreateSession(parsed.data.tag, parsed.data.metadata, parsed.data.agentState ?? null)
|
||||
const namespace = c.get('namespace')
|
||||
const session = engine.getOrCreateSession(parsed.data.tag, parsed.data.metadata, parsed.data.agentState ?? null, namespace)
|
||||
return c.json({ session })
|
||||
})
|
||||
|
||||
@@ -66,11 +106,12 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono {
|
||||
return c.json({ error: 'Not ready' }, 503)
|
||||
}
|
||||
const sessionId = c.req.param('id')
|
||||
const session = engine.getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: 'Session not found' }, 404)
|
||||
const namespace = c.get('namespace')
|
||||
const resolved = resolveSessionForNamespace(engine, sessionId, namespace)
|
||||
if (!resolved.ok) {
|
||||
return c.json({ error: resolved.error }, resolved.status)
|
||||
}
|
||||
return c.json({ session })
|
||||
return c.json({ session: resolved.session })
|
||||
})
|
||||
|
||||
app.get('/sessions/:id/messages', (c) => {
|
||||
@@ -79,9 +120,10 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono {
|
||||
return c.json({ error: 'Not ready' }, 503)
|
||||
}
|
||||
const sessionId = c.req.param('id')
|
||||
const session = engine.getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: 'Session not found' }, 404)
|
||||
const namespace = c.get('namespace')
|
||||
const resolved = resolveSessionForNamespace(engine, sessionId, namespace)
|
||||
if (!resolved.ok) {
|
||||
return c.json({ error: resolved.error }, resolved.status)
|
||||
}
|
||||
|
||||
const parsed = getMessagesQuerySchema.safeParse(c.req.query())
|
||||
@@ -105,7 +147,12 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono {
|
||||
return c.json({ error: 'Invalid body' }, 400)
|
||||
}
|
||||
|
||||
const machine = engine.getOrCreateMachine(parsed.data.id, parsed.data.metadata, parsed.data.daemonState ?? null)
|
||||
const namespace = c.get('namespace')
|
||||
const existing = engine.getMachine(parsed.data.id)
|
||||
if (existing && existing.namespace !== namespace) {
|
||||
return c.json({ error: 'Machine access denied' }, 403)
|
||||
}
|
||||
const machine = engine.getOrCreateMachine(parsed.data.id, parsed.data.metadata, parsed.data.daemonState ?? null, namespace)
|
||||
return c.json({ machine })
|
||||
})
|
||||
|
||||
@@ -115,11 +162,12 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono {
|
||||
return c.json({ error: 'Not ready' }, 503)
|
||||
}
|
||||
const machineId = c.req.param('id')
|
||||
const machine = engine.getMachine(machineId)
|
||||
if (!machine) {
|
||||
return c.json({ error: 'Machine not found' }, 404)
|
||||
const namespace = c.get('namespace')
|
||||
const resolved = resolveMachineForNamespace(engine, machineId, namespace)
|
||||
if (!resolved.ok) {
|
||||
return c.json({ error: resolved.error }, resolved.status)
|
||||
}
|
||||
return c.json({ machine })
|
||||
return c.json({ machine: resolved.machine })
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
|
||||
import { streamSSE } from 'hono/streaming'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { SSEManager } from '../../sse/sseManager'
|
||||
import type { SyncEngine } from '../../sync/syncEngine'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
|
||||
function parseOptionalId(value: string | undefined): string | null {
|
||||
@@ -18,7 +19,10 @@ function parseBoolean(value: string | undefined): boolean {
|
||||
return value === 'true' || value === '1'
|
||||
}
|
||||
|
||||
export function createEventsRoutes(getSseManager: () => SSEManager | null): Hono<WebAppEnv> {
|
||||
export function createEventsRoutes(
|
||||
getSseManager: () => SSEManager | null,
|
||||
getSyncEngine: () => SyncEngine | null
|
||||
): Hono<WebAppEnv> {
|
||||
const app = new Hono<WebAppEnv>()
|
||||
|
||||
app.get('/events', (c) => {
|
||||
@@ -32,10 +36,37 @@ export function createEventsRoutes(getSseManager: () => SSEManager | null): Hono
|
||||
const sessionId = parseOptionalId(query.sessionId)
|
||||
const machineId = parseOptionalId(query.machineId)
|
||||
const subscriptionId = randomUUID()
|
||||
const namespace = c.get('namespace')
|
||||
|
||||
if (sessionId || machineId) {
|
||||
const engine = getSyncEngine()
|
||||
if (!engine) {
|
||||
return c.json({ error: 'Not connected' }, 503)
|
||||
}
|
||||
if (sessionId) {
|
||||
const session = engine.getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: 'Session not found' }, 404)
|
||||
}
|
||||
if (session.namespace !== namespace) {
|
||||
return c.json({ error: 'Session access denied' }, 403)
|
||||
}
|
||||
}
|
||||
if (machineId) {
|
||||
const machine = engine.getMachine(machineId)
|
||||
if (!machine) {
|
||||
return c.json({ error: 'Machine not found' }, 404)
|
||||
}
|
||||
if (machine.namespace !== namespace) {
|
||||
return c.json({ error: 'Machine access denied' }, 403)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
manager.subscribe({
|
||||
id: subscriptionId,
|
||||
namespace,
|
||||
all,
|
||||
sessionId,
|
||||
machineId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Context } from 'hono'
|
||||
import type { Session, SyncEngine } from '../../sync/syncEngine'
|
||||
import type { Machine, Session, SyncEngine } from '../../sync/syncEngine'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
|
||||
export function requireSyncEngine(
|
||||
@@ -19,10 +19,14 @@ export function requireSession(
|
||||
sessionId: string,
|
||||
options?: { requireActive?: boolean }
|
||||
): Session | Response {
|
||||
const namespace = c.get('namespace')
|
||||
const session = engine.getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: 'Session not found' }, 404)
|
||||
}
|
||||
if (session.namespace !== namespace) {
|
||||
return c.json({ error: 'Session access denied' }, 403)
|
||||
}
|
||||
if (options?.requireActive && !session.active) {
|
||||
return c.json({ error: 'Session is inactive' }, 409)
|
||||
}
|
||||
@@ -43,3 +47,18 @@ export function requireSessionFromParam(
|
||||
return { sessionId, session }
|
||||
}
|
||||
|
||||
export function requireMachine(
|
||||
c: Context<WebAppEnv>,
|
||||
engine: SyncEngine,
|
||||
machineId: string
|
||||
): Machine | Response {
|
||||
const namespace = c.get('namespace')
|
||||
const machine = engine.getMachine(machineId)
|
||||
if (!machine) {
|
||||
return c.json({ error: 'Machine not found' }, 404)
|
||||
}
|
||||
if (machine.namespace !== namespace) {
|
||||
return c.json({ error: 'Machine access denied' }, 403)
|
||||
}
|
||||
return machine
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import type { SyncEngine } from '../../sync/syncEngine'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
import { requireMachine } from './guards'
|
||||
|
||||
const spawnBodySchema = z.object({
|
||||
directory: z.string().min(1),
|
||||
@@ -24,7 +25,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
return c.json({ error: 'Not connected' }, 503)
|
||||
}
|
||||
|
||||
const machines = engine.getOnlineMachines()
|
||||
const namespace = c.get('namespace')
|
||||
const machines = engine.getOnlineMachinesByNamespace(namespace)
|
||||
return c.json({ machines })
|
||||
})
|
||||
|
||||
@@ -35,9 +37,9 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
}
|
||||
|
||||
const machineId = c.req.param('id')
|
||||
const machine = engine.getMachine(machineId)
|
||||
if (!machine) {
|
||||
return c.json({ error: 'Machine not found' }, 404)
|
||||
const machine = requireMachine(c, engine, machineId)
|
||||
if (machine instanceof Response) {
|
||||
return machine
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null)
|
||||
@@ -64,9 +66,9 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
}
|
||||
|
||||
const machineId = c.req.param('id')
|
||||
const machine = engine.getMachine(machineId)
|
||||
if (!machine) {
|
||||
return c.json({ error: 'Machine not found' }, 404)
|
||||
const machine = requireMachine(c, engine, machineId)
|
||||
if (machine instanceof Response) {
|
||||
return machine
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null)
|
||||
|
||||
@@ -78,7 +78,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
|
||||
const getPendingCount = (s: Session) => s.agentState?.requests ? Object.keys(s.agentState.requests).length : 0
|
||||
|
||||
const sessions = engine.getSessions()
|
||||
const namespace = c.get('namespace')
|
||||
const sessions = engine.getSessionsByNamespace(namespace)
|
||||
.sort((a, b) => {
|
||||
// Active sessions first
|
||||
if (a.active !== b.active) {
|
||||
|
||||
Reference in New Issue
Block a user