refactor(stage4): 集中配置与启动校验,权限守卫 fail-closed,凭据移出仓库

配置分层
- 新增 src/server/config.ts:环境变量集中读取 + 启动期校验。
  删除公开兜底密钥 ln-bi-default-secret:SSO 模式下 JWT_SECRET 缺失或
  短于 32 字符时 assertRuntimeConfig() 直接拒绝启动(已实测)。
- index.ts 不再自己读端口/环境;auth/config.ts 与 auth/login.ts 改为引用集中配置。

权限守卫
- energy / scheduling / hydrogen-heatmap 的守卫由 “user 存在才校验” 改为
  “无角色即拒绝”:user 缺失时按无权限处理,不再静默放行(fail-closed)。
- /api/ele/* 此前完全无鉴权,任何已登录用户都能写入电费表;现按能源域
  (BI-LEADER-ENERGY) 守卫。

接口契约
- 未匹配的 /api/* 由 200 text/html(SPA) 改为 404 application/json;
  未认证时仍是 401,避免向未授权调用方暴露路由是否存在。
- 新增全局 onError 返回 JSON 500。

凭据治理(此前均为 git 跟踪文件中的明文)
- Dockerfile 删除烧进镜像的 JWT_SECRET,改为必须运行时注入。
- docker-compose.yml 删除生产库口令/JWT 密钥/失效的 MILEAGE_DB_*,
  改为强制注入写法;补齐 OSS_* 与 NODE_ENV/DEV_BYPASS_AUTH/BI_AUTH_*。
- woodpecker.yml 删除 Harbor base64 凭据改用 secret,pull_request 不再推镜像。
- 删除 scripts-tmp/(含生产库 root 口令)与已跟踪的 .DS_Store;.gitignore 补全。
- 文档中残留的里程库口令改为占位符。

lint / test(128) / build 全绿。
This commit is contained in:
dsh-agent
2026-09-11 10:19:07 +08:00
parent 680c7e0662
commit 9a9c9d08e1
20 changed files with 176 additions and 297 deletions
Vendored
BIN
View File
Binary file not shown.
+2
View File
@@ -3,3 +3,5 @@ dist
.env
.env.local
.worktrees
.DS_Store
scripts-tmp
+3 -1
View File
@@ -17,7 +17,9 @@ COPY src/shared ./src/shared
COPY tsconfig.json ./
EXPOSE 3001
ENV NODE_ENV=production
ENV SERVER_PORT=3001
ENV EXTERNAL_API_BASE=https://lnh2e.com
ENV JWT_SECRET=ln-bi-jwt-prod-secret
# JWT_SECRET 不再写入镜像:镜像层里的默认密钥等同于公开密钥,任何人可离线伪造令牌。
# 必须在运行环境注入(至少 32 字符),缺失时服务会拒绝启动(见 src/server/config.ts)。
CMD ["npm", "run", "start"]
+30 -12
View File
@@ -1,20 +1,29 @@
version: '3.8'
# 凭据一律通过 Portainer / 环境变量注入,不再写入本文件。
# 注意:此前提交到 Git 的数据库口令与 JWT 密钥已视为泄漏,必须轮换后再使用。
# JWT_SECRET 至少要 32 个字符,缺失时服务会拒绝启动(src/server/config.ts)。
services:
ln-bi:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.1.15
network_mode: host
environment:
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
DB_PORT: "3306"
DB_USER: "oneos_db_prod"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
DB_NAME: "ln_asset_management"
MILEAGE_DB_HOST: "101.133.130.65"
MILEAGE_DB_PORT: "3306"
MILEAGE_DB_USER: "bi_reader_02"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
MILEAGE_DB_NAME: "hydrogen_energy"
NODE_ENV: "production"
# 生产必须保持为 0:该开关会绕过全部认证。
DEV_BYPASS_AUTH: "0"
DB_HOST: "${DB_HOST:?请注入主业务库地址}"
DB_PORT: "${DB_PORT:-3306}"
DB_USER: "${DB_USER:?请注入主业务库只读账号}"
DB_PASSWORD: "${DB_PASSWORD:?请注入主业务库口令}"
DB_NAME: "${DB_NAME:-ln_asset_management}"
# 氢能库未配置时复用主库,因此默认不再单独注入;如需独立只读库再配置。
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:-}"
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:-}"
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:-}"
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:-}"
HYDROGEN_TENANT_ID: "${HYDROGEN_TENANT_ID:-000000}"
# ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file.
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
@@ -22,7 +31,9 @@ services:
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
BI_AUTH_MODE: "${BI_AUTH_MODE:-sso}"
BI_AUTH_PASSWORD: "${BI_AUTH_PASSWORD:-}"
JWT_SECRET: "${JWT_SECRET:?请注入至少 32 字符的随机签名密钥}"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
@@ -31,6 +42,13 @@ services:
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
# 用户反馈截图上传所需;此前缺失会导致反馈上传必然失败。
OSS_REGION: "${OSS_REGION}"
OSS_ENDPOINT: "${OSS_ENDPOINT}"
OSS_BUCKET: "${OSS_BUCKET}"
OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID}"
OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET}"
OSS_BASE_DIR: "${OSS_BASE_DIR}"
deploy:
replicas: 1
restart_policy:
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -43,7 +43,7 @@ const mileagePool = mysql.createPool({
host: '101.133.130.65',
port: 3306,
user: 'bi_reader_02',
password: 'bi_reader_02_Pass',
password: process.env.MILEAGE_DB_PASSWORD,
database: 'hydrogen_energy',
waitForConnections: true,
connectionLimit: 5,
@@ -20,7 +20,7 @@
### 数据库 2hydrogen_energy(新增连接)
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `bi_reader_02_Pass`,库名 `hydrogen_energy`
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `<见环境变量 MILEAGE_DB_PASSWORD>`,库名 `hydrogen_energy`
- `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source
## 架构
-178
View File
@@ -1,178 +0,0 @@
沪A00113F
沪A00220F
沪A00333F
沪A00607F
沪A01056F
沪A01311F
沪A01775F
沪A01813F
沪A01855F
沪A02303F
沪A02311F
沪A02326F
沪A02361F
沪A02720F
沪A03086F
沪A03397F
沪A03565F
沪A03620F
沪A03659F
沪A03801F
沪A03870F
沪A05035F
沪A05113F
沪A05223F
沪A05501F
沪A05675F
沪A05697F
沪A05830F
沪A06335F
沪A06599F
沪A06695F
沪A07006F
沪A07153F
沪A07806F
沪A08037F
沪A08150F
沪A08315F
沪A08598F
沪A08786F
沪A09100F
沪A09251F
沪A09276F
沪A09303F
沪A09313F
沪A09322F
沪A09689F
沪A30010F
沪A30399F
沪A31031F
沪A31211F
沪A31281F
沪A31308F
沪A31381F
沪A31613F
沪A32269F
沪A33216F
沪A35236F
沪A35798F
沪A35879F
沪A35898F
沪A36133F
沪A36169F
沪A36569F
沪A36980F
沪A37785F
沪A38795F
沪A39287F
沪A39289F
沪A39585F
沪A39608F
沪A39626F
沪A39815F
沪A39835F
沪A39912F
沪A50026F
沪A50069F
沪A50309F
沪A51580F
沪A51612F
沪A51677F
沪A51893F
沪A52331F
沪A52511F
沪A53309F
沪A53322F
沪A53506F
沪A53960F
沪A55179F
沪A55297F
沪A55339F
沪A55666F
沪A55695F
沪A56122F
沪A56701F
沪A56959F
沪A56988F
沪A57139F
沪A57167F
沪A57198F
沪A57838F
沪A57850F
沪A57895F
沪A58087F
沪A58159F
沪A58185F
沪A58307F
沪A58533F
沪A58538F
沪A58593F
沪A58922F
沪A59095F
沪A59510F
沪A59613F
沪A59682F
沪A59799F
沪A59932F
沪A60339F
沪A60691F
沪A60820F
沪A61187F
沪A61193F
沪A61312F
沪A61559F
沪A61600F
沪A61711F
沪A61738F
沪A62322F
沪A62772F
沪A62928F
沪A63013F
沪A63305F
沪A63522F
沪A63660F
沪A63697F
沪A65036F
沪A65181F
沪A65522F
沪A65995F
沪A66216F
沪A66256F
沪A66329F
沪A66593F
沪A66710F
沪A66921F
沪A67018F
沪A67033F
沪A67872F
沪A68115F
沪A68139F
沪A68332F
沪A68613F
沪A68658F
沪A68752F
沪A69311F
沪A69826F
沪A69997F
沪A85021F
沪A89315F
沪A89385F
沪A89662F
浙F00885F
浙F08889F
浙F09898F
粤A00255F
粤A02683F
粤A02956F
粤A03502F
粤A03532F
粤A03569F
粤A05106F
粤A05391F
粤A05428F
粤A05839F
粤A05985F
粤A05995F
粤A06569F
粤A06931F
粤A06932F
-60
View File
@@ -1,60 +0,0 @@
import mysql from 'mysql2/promise';
import fs from 'node:fs';
const pool = mysql.createPool({
host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com',
port: 3306,
user: 'root',
password: 'LN#Passw0rd@2026',
database: 'lingniu_prod',
connectTimeout: 15000, ssl: { rejectUnauthorized: false },
});
async function main() {
const excelPlates = new Set(
fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim())
);
console.log('excel plates:', excelPlates.size);
// 按 dept-stats 逻辑查金可鹏 18T Operating
const [rows] = await pool.query<any[]>(`
SELECT truck.plate_number AS plate,
dic_type.dic_name AS type_label,
dic_status.dic_name AS status_label,
cus.customer_name AS customer,
org_truck.org_name AS subject_org
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code='dic_truck_type' AND dic_type.dic_code=truck.model AND dic_type.is_deleted=0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code='dic_truck_rent_status' AND dic_status.dic_code=truck.truck_rent_status AND dic_status.is_deleted=0
LEFT JOIN tab_truck_status_info si ON si.truck_id=truck.id AND si.is_deleted=0
LEFT JOIN tab_contract c ON c.id=si.contract_id AND c.is_deleted=0
LEFT JOIN tab_customer cus ON cus.id=c.customer_id AND cus.is_deleted=0
LEFT JOIN tab_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0
LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0
WHERE truck.is_deleted=0 AND truck.is_operation=1
AND u.user_name='金可鹏'
AND dic_type.dic_name LIKE '%18吨%'
AND dic_status.dic_name IN ('租赁','自营','挂靠')
ORDER BY truck.plate_number
`);
console.log('DB 金可鹏 18T operating:', rows.length);
const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim()));
const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort();
const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort();
console.log('\n=== DB 有但 Excel 没有(多出来的) ===');
console.log('数量:', extra.length);
for (const p of extra) {
const r = (rows as any[]).find((x) => x.plate === p);
console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org);
}
console.log('\n=== Excel 有但 DB 没有 ===');
console.log('数量:', missing.length);
for (const p of missing) console.log(' ', p);
await pool.end();
}
main().catch((e) => { console.error(e); process.exit(1); });
+11
View File
@@ -41,6 +41,17 @@ export function createApp(): Hono {
time: new Date().toISOString(),
}));
// 未匹配的 /api/* 必须返回 JSON 404。若落到下面的静态回退,会返回
// 200 + index.html,让前端 res.ok 为真、再在 res.json() 处抛解析错误,
// 把"路由不存在"伪装成数据问题。
app.all('/api/*', (context) => context.json({ error: 'Not Found' }, 404));
// 兜底错误处理:统一返回 JSON,避免把栈信息或 HTML 暴露给调用方。
app.onError((error, context) => {
console.error(`[server] unhandled error on ${context.req.method} ${context.req.path}:`, error);
return context.json({ error: 'Internal Server Error' }, 500);
});
// 生产环境由同一进程托管 Vite 构建产物,并回退到单页应用入口。
app.use('/*', serveStatic({ root: './dist' }));
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
+18 -10
View File
@@ -1,24 +1,32 @@
import { createHmac } from 'node:crypto';
import jwt from 'jsonwebtoken';
import type { JwtPayload } from './types.js';
import { authMode, jwtSecret } from '../config.js';
export function authMode() {
const mode = process.env.BI_AUTH_MODE || 'sso';
if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE');
return mode;
}
// 认证方式统一由 server/config.ts 读取与校验,这里只做转发,
// 让既有的 `from './config.js'` 引用保持有效。
export { authMode };
export function passwordConfig() {
const password = process.env.BI_AUTH_PASSWORD || '';
const secret = process.env.JWT_SECRET || '';
if (!password.trim() || secret.length < 32) throw new Error('Password authentication is not configured securely');
return { password, key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex') };
const secret = jwtSecret();
if (!password.trim() || secret.length < 32) {
throw new Error('Password authentication is not configured securely');
}
return {
password,
key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex'),
};
}
export function verifyAuthToken(token: string): JwtPayload {
const passwordMode = authMode() === 'password';
const key = passwordMode ? passwordConfig().key : process.env.JWT_SECRET || 'ln-bi-default-secret';
// 不再有公开兜底密钥:SSO 模式下 JWT_SECRET 缺失会在启动期被 assertRuntimeConfig 拦下。
const key = passwordMode ? passwordConfig().key : jwtSecret();
if (!key) throw new Error('JWT_SECRET is not configured');
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') throw new Error('Authentication mode changed');
if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') {
throw new Error('Authentication mode changed');
}
return payload;
}
+3 -5
View File
@@ -5,13 +5,11 @@ import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
import { authMode, verifyAuthToken } from './config.js';
import { passwordRouter } from './password.js';
import { jwtSecret, serverConfig } from '../config.js';
const app = new Hono();
app.route('/', passwordRouter());
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
app.get('/exchange', async (c) => {
if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403);
@@ -20,7 +18,7 @@ app.get('/exchange', async (c) => {
try {
const res = await fetch(
`${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}`
`${serverConfig.externalApiBase}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}`
);
const data = await res.json() as {
code: number;
@@ -77,7 +75,7 @@ app.get('/exchange', async (c) => {
roles: roleNames,
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
const token = jwt.sign(payload, jwtSecret(), { expiresIn: '8h' });
const authUser: AuthUser = { ...payload };
return c.json({ token, user: authUser });
+65
View File
@@ -0,0 +1,65 @@
import dotenv from 'dotenv';
// ESM 的 import 会被提升到调用方语句之前执行,所以这里显式加载一次环境文件,
// 保证本模块下面的常量真的读到了 .env。dotenv 是幂等的(不覆盖已有变量),
// 各数据库模块仍保留自己的 dotenv 调用以规避模块初始化顺序问题。
dotenv.config();
function readNumber(value: string | undefined, fallback: number): number {
if (value === undefined || value.trim() === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
/** 认证方式:`sso` 走业务系统跳转换票,`password` 只用固定密码。 */
export type AuthMode = 'sso' | 'password';
export function authMode(): AuthMode {
const mode = process.env.BI_AUTH_MODE || 'sso';
if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE');
return mode;
}
export const serverConfig = {
port: readNumber(process.env.SERVER_PORT, 3001),
externalApiBase: process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com',
} as const;
/**
* JWT 签名密钥。
* 刻意不提供兜底值:历史上 `'ln-bi-default-secret'` 这类公开常量会让任何人
* 离线伪造全权限令牌。缺失或过短一律由 {@link assertRuntimeConfig} 拒绝启动。
*/
export function jwtSecret(): string {
return process.env.JWT_SECRET || '';
}
/** 生产只读预览:DB_READ_ONLY=1 时禁止业务写入且不启动后台任务。 */
export const isDbReadOnly = () => process.env.DB_READ_ONLY === '1';
/** 氢能查询只读:对氢能连接池启用 SELECT/SHOW/WITH/EXPLAIN 白名单。 */
export const isHydrogenReadOnly = () => process.env.HYDROGEN_DB_READ_ONLY === '1';
const MIN_SECRET_LENGTH = 32;
/**
* 启动期配置校验。在监听端口之前调用,让配置错误在启动时暴露而不是在第一次请求时。
* 只检查"缺失会导致安全问题"的项,不检查业务数据库等可选能力。
*/
export function assertRuntimeConfig(): void {
if (authMode() === 'sso') {
const secret = jwtSecret();
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`JWT_SECRET must be set to a random value of at least ${MIN_SECRET_LENGTH} characters `
+ '(SSO 模式下缺失会让任何人都能伪造令牌)',
);
}
}
if (authMode() === 'password') {
const password = (process.env.BI_AUTH_PASSWORD || '').trim();
if (!password) throw new Error('BI_AUTH_PASSWORD must be set when BI_AUTH_MODE=password');
if (jwtSecret().length < MIN_SECRET_LENGTH) {
throw new Error(`JWT_SECRET must be at least ${MIN_SECRET_LENGTH} characters`);
}
}
}
+6 -6
View File
@@ -1,15 +1,15 @@
import { serve } from '@hono/node-server';
import dotenv from 'dotenv';
import { createApp } from './app.js';
import { startBackgroundServices } from './bootstrap.js';
import { assertRuntimeConfig, serverConfig } from './config.js';
dotenv.config();
// 配置错误在启动期暴露,而不是等到第一次请求才 500。
assertRuntimeConfig();
const app = createApp();
const port = Number(process.env.SERVER_PORT) || 3001;
console.log(`Server starting on port ${port}...`);
console.log(`Server starting on port ${serverConfig.port}...`);
startBackgroundServices();
serve({ fetch: app.fetch, port }, () => {
console.log(`Server running at http://localhost:${port}`);
serve({ fetch: app.fetch, port: serverConfig.port }, () => {
console.log(`Server running at http://localhost:${serverConfig.port}`);
});
+12
View File
@@ -2,10 +2,22 @@ import { Hono } from 'hono';
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
import * as XLSX from 'xlsx';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { ensureChargeRecordTable } from './migration.js';
const app = new Hono();
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。
app.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 电能导入需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
// 与 xlsx 列名对齐
const COL = {
orderNo: '订单编号',
+3 -3
View File
@@ -14,11 +14,11 @@ import { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js';
const app = new Hono();
// 模块级访问守卫dev 旁路 auth 时 user 为 undefined,直接放行;
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
// 模块级访问守卫fail-closed):必须持有 BI-LEADER-ENERGY 或「所有权限」。
// 认证中间件挂在本路由之前,因此 user 缺失本身就是异常,按无权限处理。
app.use('*', async (c, next) => {
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
+1 -1
View File
@@ -82,7 +82,7 @@ const router = new Hono();
router.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
+4 -4
View File
@@ -6,12 +6,12 @@ import { canAccessScheduling } from '../../auth/types.js';
const app = new Hono();
// Module-level access guard. When auth middleware is active, `user` is set and
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
// When auth is bypassed (dev), `user` is undefined and requests pass through.
// Module-level access guard (fail-closed): requires BI-SCHEDULE-OPT.
// Auth middleware runs before this router, so a missing `user` is an anomaly and
// must be treated as "no permission" rather than silently allowed.
app.use('*', async (c, next) => {
const user = (c as any).get('user') as AuthUser | undefined;
if (user && !canAccessScheduling(user.roles)) {
if (!canAccessScheduling(user?.roles)) {
return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
}
return next();
+16 -15
View File
@@ -1,3 +1,7 @@
# 凭据通过 Woodpecker Secret 注入(harbor_user / harbor_password),
# 不再把 Harbor 账号密码 base64 后写进流水线脚本。
# 镜像推送只在 push / manual 触发:pull_request 不应向生产 registry 推送。
steps:
- name: npm-build
image: node:22-alpine
@@ -18,7 +22,7 @@ steps:
npm run build
# 获取分支名
BRANCH_NAME=$(echo $CI_COMMIT_BRANCH | tr / -)
BRANCH_NAME=$(echo ${CI_COMMIT_BRANCH:-manual} | tr / -)
echo "Branch name: $BRANCH_NAME"
# 版本号: 分支名-package.json版本
@@ -29,10 +33,10 @@ steps:
- name: docker-build
image: docker:24.0.5-cli
depends_on: [npm-build]
when:
event:
- push
- pull_request
- manual
branch:
- master
@@ -40,25 +44,22 @@ steps:
- main
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
HARBOR_USER:
from_secret: harbor_user
HARBOR_PASSWORD:
from_secret: harbor_password
commands: |
PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
MODULE_NAME=ln-bi
REGISTRY=harbor.lnh2e.com
PROJECT=lingniu-v1
echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION"
cd $CI_WORKSPACE
docker build -t $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION .
docker build -t harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION .
echo "$HARBOR_PASSWORD" | docker login $REGISTRY -u "$HARBOR_USER" --password-stdin
mkdir -p /root/.docker
cat > /root/.docker/config.json <<EOF
{
"auths": {
"harbor.lnh2e.com": {
"auth": "$(echo Y2ljZDpMbkBjaWNkMDE=)"
}
}
}
EOF
docker push harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION
docker push $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION