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:
weishu
2025-12-31 21:56:01 +08:00
parent 460c393006
commit e821458af8
30 changed files with 737 additions and 124 deletions
+7 -2
View File
@@ -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')
+10 -3
View File
@@ -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')
+65 -17
View File
@@ -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
+32 -1
View File
@@ -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,
+20 -1
View File
@@ -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
}
+9 -7
View File
@@ -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)
+2 -1
View File
@@ -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) {