fix(auth): allow read-only asset queries for password accounts
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Context, Next } from 'hono';
|
||||
import { isReadOnlyRequest } from './read-only-policy.js';
|
||||
import type { AuthUser } from '../auth/types.js';
|
||||
import { authMode, verifyAuthToken } from '../auth/config.js';
|
||||
|
||||
@@ -42,7 +43,7 @@ export async function authMiddleware(c: Context, next: Next) {
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
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');
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { isReadOnlyRequest } from './read-only-policy.js';
|
||||
|
||||
/** Guard preview write endpoints before auth/route handlers can perform work. */
|
||||
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'
|
||||
&& context.req.path !== '/api/auth/password'
|
||||
&& !isAssetQuery
|
||||
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
|
||||
&& !isReadOnlyRequest(context.req.method, context.req.path)) {
|
||||
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
|
||||
}
|
||||
return next();
|
||||
|
||||
Reference in New Issue
Block a user