fix(auth): allow read-only asset queries for password accounts
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-23 14:42:55 +08:00
parent 269e3139a3
commit 1a48b607b2
4 changed files with 62 additions and 5 deletions
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { passwordRouter } from '../auth/password.js';
import { authMiddleware } from './auth.js';
import { readOnlyMiddleware } from './read-only.js';
import assistantRouter from '../routes/asset-assistant/index.js';
import { vehicleRepository } from '../routes/vehicles/repository.js';
test('password login can query assets through both guards without gaining write access', async (t) => {
const keys = ['BI_AUTH_MODE', 'BI_AUTH_PASSWORD', 'JWT_SECRET', 'DEV_BYPASS_AUTH', 'DB_READ_ONLY', 'DEEPSEEK_API_KEY'] as const;
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
t.after(() => { for (const key of keys) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; } });
Object.assign(process.env, { BI_AUTH_MODE: 'password', BI_AUTH_PASSWORD: 'test-password-only', JWT_SECRET: 'test-signing-secret-with-at-least-32-characters', DEV_BYPASS_AUTH: '0', DEEPSEEK_API_KEY: 'test-only' });
t.mock.method(vehicleRepository, 'getVehicles', async () => []);
let modelCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
modelCalls++;
return new Response(JSON.stringify({ choices: [{ message: { content: modelCalls % 2 ? JSON.stringify({kind:'assets',category:'inventory',groupBy:'region',view:'table',filters:{}}) : '当前范围暂无库存。' }, finish_reason:'stop' }] }));
});
const app = new Hono();
app.use('/api/*', readOnlyMiddleware);
app.route('/api/auth', passwordRouter());
app.use('/api/*', authMiddleware);
app.route('/api/asset-assistant', assistantRouter);
let fallbackCalls = 0;
app.all('*', c => { fallbackCalls++; return c.text('ok'); });
for (const readOnly of ['0', '1']) {
process.env.DB_READ_ONLY = readOnly;
const login = await app.request('/api/auth/password', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({password:process.env.BI_AUTH_PASSWORD}) });
assert.equal(login.status, 200);
const { token } = await login.json();
const headers = { Authorization:`Bearer ${token}`, 'Content-Type':'application/json' };
const response = await app.request('/api/asset-assistant/ask', {method:'POST', headers, body:JSON.stringify({message:'各区域的库存车辆有多少?按数量排序。'})});
assert.equal(response.status, 200, `DB_READ_ONLY=${readOnly}: ${response.status === 200 ? '' : await response.text()}`);
assert.match(response.headers.get('content-type') || '', /text\/event-stream/);
assert.match(await response.text(), /"type":"result"/);
const before = modelCalls;
for (const authorization of [undefined, 'Bearer invalid']) {
const requestHeaders: Record<string, string> = authorization ? {Authorization:authorization} : {};
assert.equal((await app.request('/api/asset-assistant/ask', {method:'POST',headers:requestHeaders,body:'{}'})).status,401);
}
for (const [method, path] of [
['POST','/api/feedback'], ['POST','/api/scheduling/confirm'],
['POST','/api/asset-assistant/other'], ['POST','/api/asset-assistant/ask/extra'],
['PUT','/api/asset-assistant/ask'], ['PATCH','/api/asset-assistant/ask'], ['DELETE','/api/asset-assistant/ask'],
]) assert.equal((await app.request(path,{method,headers})).status,403, `${method} ${path}`);
assert.equal(modelCalls,before,'denied requests must not contact the model');
assert.equal(fallbackCalls,0,'write handlers must not run');
}
assert.equal(modelCalls,4);
});
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Context, Next } from 'hono'; import type { Context, Next } from 'hono';
import { isReadOnlyRequest } from './read-only-policy.js';
import type { AuthUser } from '../auth/types.js'; import type { AuthUser } from '../auth/types.js';
import { authMode, verifyAuthToken } from '../auth/config.js'; import { authMode, verifyAuthToken } from '../auth/config.js';
@@ -42,7 +43,7 @@ export async function authMiddleware(c: Context, next: Next) {
try { try {
const payload = verifyAuthToken(token); const payload = verifyAuthToken(token);
if (payload.authMethod === 'password' && !['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { if (payload.authMethod === 'password' && !isReadOnlyRequest(c.req.method, path)) {
return c.json({ error: 'Password account is read-only' }, 403); return c.json({ error: 'Password account is read-only' }, 403);
} }
const user: AuthUser = { const user: AuthUser = {
@@ -0,0 +1,6 @@
/** Query POSTs must be explicitly audited; never allow an entire route prefix. */
export function isReadOnlyRequest(method: string, path: string): boolean {
return ['GET', 'HEAD', 'OPTIONS'].includes(method)
// Interprets a question and reads permission-filtered assets; no business writes.
|| (method === 'POST' && path === '/api/asset-assistant/ask');
}
+2 -4
View File
@@ -1,13 +1,11 @@
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
import { isReadOnlyRequest } from './read-only-policy.js';
/** Guard preview write endpoints before auth/route handlers can perform work. */ /** Guard preview write endpoints before auth/route handlers can perform work. */
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => { export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
// This POST only interprets queries and reads assets; it never writes business data.
const isAssetQuery = context.req.method === 'POST' && context.req.path === '/api/asset-assistant/ask';
if (process.env.DB_READ_ONLY === '1' if (process.env.DB_READ_ONLY === '1'
&& context.req.path !== '/api/auth/password' && context.req.path !== '/api/auth/password'
&& !isAssetQuery && !isReadOnlyRequest(context.req.method, context.req.path)) {
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403); return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
} }
return next(); return next();