Commit 2c1e4a14 by luoqi

Merge Friday host auth policy fix into test

parents 6939bd13 1211c4ab
Pipeline #3780 failed in 0 seconds
...@@ -68,6 +68,10 @@ iframe 内的 token 落地与刷新由 PAC 前端完成。`refreshToken` 过期( ...@@ -68,6 +68,10 @@ iframe 内的 token 落地与刷新由 PAC 前端完成。`refreshToken` 过期(
| `user.orgScope` | string[] | ✅ | 数据权限:用户可达的组织节点 id 列表;传 `[]` = 全集团不限。见下「`tenantId` 与 `orgScope`」 | | `user.orgScope` | string[] | ✅ | 数据权限:用户可达的组织节点 id 列表;传 `[]` = 全集团不限。见下「`tenantId` 与 `orgScope`」 |
| `user.dictionary` | object | 选填 | `id → 名` 映射,仅用于页面把 id 显示成名字,**不影响权限**。含 `clinics`、`users` 两个子字典。建议提供,否则页面显示原始 id | | `user.dictionary` | object | 选填 | `id → 名` 映射,仅用于页面把 id 显示成名字,**不影响权限**。含 `clinics`、`users` 两个子字典。建议提供,否则页面显示原始 id |
**Friday SaaS 换票边界**:服务端 `data/friday/manifest.yaml` 的 `auth` 策略限制最高角色为 `leader`,组织范围不能为空或包含空白项;数据租户必须为 `friday-market`,范围不能包含市场根 `friday-market`。品牌管理员应传本品牌节点,诊所用户传所属诊所节点。拒绝返回业务码 `10107`,不会签发令牌。策略同样用于刷新令牌;Friday 配置缺失或损坏时拒绝签发。未配置策略的其他宿主保持原行为(如 jvs-dw 集团管理员)。
宿主后端仍须核实用户身份、角色及所传品牌/诊所归属;PAC 这道上限校验不替代宿主用户授权。Friday 跨品牌运营账号不能复用普通品牌换票入口。已有 access token 和短期换票 code 不会被本次修改主动撤销,生效前如需立即收回旧授权,须另行安排令牌撤销;超范围 refresh token 无法续期。本次不改变开发用 mock-login 的入口权限,生产应关闭模拟登录。
> `orgScope` 字段本身必传(哪怕是空数组);请求体为严格模式,未定义的字段会被拒绝。 > `orgScope` 字段本身必传(哪怕是空数组);请求体为严格模式,未定义的字段会被拒绝。
**`role`** — 功能权限: **`role`** — 功能权限:
......
...@@ -17,6 +17,12 @@ ...@@ -17,6 +17,12 @@
# pnpm sync -- --dir=./data/friday --dry-run # 看翻译效果,不写库 # pnpm sync -- --dir=./data/friday --dry-run # 看翻译效果,不写库
# pnpm sync -- --dir=./data/friday --no-recompute # pnpm sync -- --dir=./data/friday --no-recompute
# 普通宿主换票只允许品牌/诊所范围;跨品牌运营不得复用此入口。
auth:
require_org_scope: true
max_role: leader
forbid_root_scope: true
host_name: friday host_name: friday
display_name: FRIDAY SaaS(多品牌市场) display_name: FRIDAY SaaS(多品牌市场)
auto_sync: false auto_sync: false
......
...@@ -27,6 +27,7 @@ import { AlertService } from '../../common/alerting/alert.service'; ...@@ -27,6 +27,7 @@ import { AlertService } from '../../common/alerting/alert.service';
import { parseDurationToSeconds } from './duration'; import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users'; import { loadMockUsers } from './mock-users';
import { OrgTreeService } from './org-tree'; import { OrgTreeService } from './org-tree';
import { checkHostAuthPolicy, loadHostAuthPolicy } from './host-auth-policy';
const CODE_PREFIX = 'pac:exchange-code:'; const CODE_PREFIX = 'pac:exchange-code:';
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 —— /// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
...@@ -145,6 +146,8 @@ export class AuthService { ...@@ -145,6 +146,8 @@ export class AuthService {
throw new BizError(ApiCode.AUTH_INVALID_CREDENTIALS); throw new BizError(ApiCode.AUTH_INVALID_CREDENTIALS);
} }
this.assertHostAuthPolicy(host.name, req.user);
// 契约 user 上下文 → token,与 mockLogin 共用同一条 issueTokens(展开 + 签发)。 // 契约 user 上下文 → token,与 mockLogin 共用同一条 issueTokens(展开 + 签发)。
const { accessToken, refreshToken } = await this.issueTokens(host, req.user); const { accessToken, refreshToken } = await this.issueTokens(host, req.user);
...@@ -493,6 +496,10 @@ export class AuthService { ...@@ -493,6 +496,10 @@ export class AuthService {
const stored = await this.redis.get(REFRESH_PREFIX + payload.jti); const stored = await this.redis.get(REFRESH_PREFIX + payload.jti);
if (!stored) throw new BizError(ApiCode.AUTH_INVALID_REFRESH_TOKEN, 'refresh token revoked'); if (!stored) throw new BizError(ApiCode.AUTH_INVALID_REFRESH_TOKEN, 'refresh token revoked');
const host = await this.prisma.host.findUnique({ where: { id: payload.hostId } });
if (!host?.active) throw new BizError(ApiCode.AUTH_INVALID_REFRESH_TOKEN);
this.assertHostAuthPolicy(host.name, payload);
// ── 步骤 3:重签 access token(payload 里有完整 user 上下文)── // ── 步骤 3:重签 access token(payload 里有完整 user 上下文)──
const permissions = this.resolvePermissions(payload.role); const permissions = this.resolvePermissions(payload.role);
const accessToken = await this.signAccessToken({ const accessToken = await this.signAccessToken({
...@@ -550,6 +557,18 @@ export class AuthService { ...@@ -550,6 +557,18 @@ export class AuthService {
return token; return token;
} }
private assertHostAuthPolicy(hostName: string, user: { role: string; orgScope: string[]; tenantId: string }): void {
let violation: string | null;
try {
violation = checkHostAuthPolicy(loadHostAuthPolicy(hostName), user);
} catch {
// 配置损坏也拒绝签发;日志不包含凭据或用户资料。
this.logger.error(`宿主换票策略不可用 host=${hostName}`);
throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '宿主权限配置不可用');
}
if (violation) throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, violation);
}
resolvePermissions(role: UserRole): Permission[] { resolvePermissions(role: UserRole): Permission[] {
return ROLE_PERMISSIONS[role]; return ROLE_PERMISSIONS[role];
} }
......
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'js-yaml';
import { z } from 'zod';
import { UserRole } from '@pac/types';
import { ManifestAuthSchema } from '../sync/cold-import/manifest.schema';
const PolicyFileSchema = z.object({
host_name: z.string(),
tenant_id: z.string().min(1).optional(),
auth: ManifestAuthSchema.optional(),
});
export type HostAuthPolicy = { auth: z.infer<typeof ManifestAuthSchema>; tenantId?: string };
const ROLE_RANK: readonly string[] = [UserRole.STAFF, UserRole.LEADER, UserRole.ADMIN];
/** 只读取服务端部署的配置;Friday 缺配置时拒绝签发,不能退回无限制。 */
export function loadHostAuthPolicy(hostName: string): HostAuthPolicy {
if (!/^[a-zA-Z0-9_-]+$/.test(hostName)) throw new Error('Invalid host name');
const dir = process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(process.cwd(), 'data');
const file = path.join(dir, hostName, 'manifest.yaml');
if (!fs.existsSync(file)) {
if (hostName === 'friday') throw new Error('Friday auth policy missing');
return { auth: ManifestAuthSchema.parse({}) };
}
const config = PolicyFileSchema.parse(yaml.load(fs.readFileSync(file, 'utf8')));
if (config.host_name !== hostName) throw new Error('Host auth policy mismatch');
if (hostName === 'friday' && (!config.auth || !config.tenant_id)) {
throw new Error('Friday auth policy incomplete');
}
const auth = ManifestAuthSchema.parse(config.auth ?? {});
if (
hostName === 'friday' &&
(!auth.require_org_scope || !auth.forbid_root_scope || auth.max_role === 'admin')
) {
throw new Error('Friday auth policy must retain brand isolation');
}
if (auth.forbid_root_scope && !config.tenant_id)
throw new Error('Root scope policy requires tenant_id');
return { auth, tenantId: config.tenant_id };
}
export function checkHostAuthPolicy(
policy: HostAuthPolicy,
user: { role: string; orgScope: string[]; tenantId: string },
): string | null {
const { auth } = policy;
if (
ROLE_RANK.indexOf(user.role) < 0 ||
ROLE_RANK.indexOf(user.role) > ROLE_RANK.indexOf(auth.max_role)
) {
return '宿主不允许签发该角色';
}
if (auth.require_org_scope && (!user.orgScope.length || user.orgScope.some((id) => !id.trim()))) {
return '该宿主必须提供非空组织范围';
}
if (auth.forbid_root_scope) {
if (user.tenantId !== policy.tenantId) return '宿主数据租户不匹配';
if (user.orgScope.some((id) => id.trim() === policy.tenantId)) return '该宿主不允许市场根范围';
}
return null;
}
import { z } from 'zod'; import { z } from 'zod';
import { UserRoleSchema } from '@pac/types';
import { TransformsSchema } from '../transforms/transforms.schema'; import { TransformsSchema } from '../transforms/transforms.schema';
/** /**
...@@ -152,10 +153,19 @@ export const DEFAULT_REVERSE_PULL_FROM = [ ...@@ -152,10 +153,19 @@ export const DEFAULT_REVERSE_PULL_FROM = [
] as const; ] as const;
export type ClickHouseSource = z.infer<typeof ClickHouseSourceSchema>; export type ClickHouseSource = z.infer<typeof ClickHouseSourceSchema>;
/** 宿主换票上限;组织属于哪个用户仍由宿主服务端核实。 */
export const ManifestAuthSchema = z.object({
require_org_scope: z.boolean().default(false),
max_role: UserRoleSchema.default('admin'),
forbid_root_scope: z.boolean().default(false),
}).strict();
export type ManifestAuth = z.infer<typeof ManifestAuthSchema>;
export const ColdImportManifestSchema = z export const ColdImportManifestSchema = z
.object({ .object({
/// 关联到 PAC hosts.name(slug,平台必须先 seed/admin 注册) /// 关联到 PAC hosts.name(slug,平台必须先 seed/admin 注册)
host_name: z.string().min(1), host_name: z.string().min(1),
auth: ManifestAuthSchema.optional(),
display_name: z.string().optional(), display_name: z.string().optional(),
/// 是否纳入每日自动增量同步(SyncIncrementalSchedulerService 启动时自动发现)。 /// 是否纳入每日自动增量同步(SyncIncrementalSchedulerService 启动时自动发现)。
......
import { join } from 'node:path';
import { hashSecret } from '@pac/utils';
import { ApiCode, UserRole } from '@pac/types';
import { AuthService } from '../src/modules/auth/auth.service';
// 只替换外部依赖;真实换票/刷新逻辑、密钥校验、manifest 读取和策略均执行。
describe('host policy in token issuance and refresh', () => {
const original = process.env.PAC_INCREMENTAL_DATA_DIR;
const valid = {
userId: 'u1',
tenantId: 'friday-market',
role: UserRole.LEADER,
orgScope: ['clinic-a'],
};
const host = {
id: 'host1',
name: 'friday',
active: true,
appSecretHash: hashSecret('test-secret'),
};
let service: AuthService;
let prisma: any, redis: any, jwt: any;
beforeEach(() => {
process.env.PAC_INCREMENTAL_DATA_DIR = join(__dirname, '../data');
prisma = { host: { findUnique: jest.fn().mockResolvedValue(host) } };
redis = {
get: jest.fn().mockResolvedValue('stored'),
setEx: jest.fn().mockResolvedValue(null),
del: jest.fn().mockResolvedValue(null),
};
jwt = {
signAsync: jest.fn().mockResolvedValue('signed-token'),
verifyAsync: jest
.fn()
.mockResolvedValue({ ...valid, sub: 'u1', hostId: 'host1', jti: 'jti1' }),
};
service = new AuthService(
prisma,
redis,
jwt,
{
getOrThrow: (key: string) =>
key === 'jwt.expiresIn' ? '1h' : key === 'jwt.refreshExpiresIn' ? '7d' : 60,
} as any,
{} as any,
{} as any,
{ trackServerSide: jest.fn().mockResolvedValue(null) } as any,
);
});
afterEach(() => {
if (original === undefined) delete process.env.PAC_INCREMENTAL_DATA_DIR;
else process.env.PAC_INCREMENTAL_DATA_DIR = original;
});
test.each([
{ ...valid, orgScope: [] },
{ ...valid, role: UserRole.ADMIN },
{ ...valid, orgScope: ['friday-market'] },
{ ...valid, tenantId: 'different' },
])('blocks invalid exchange before signing or storing code', async (user) => {
await expect(
service.exchangeToken({ appId: 'app1', appSecret: 'test-secret', user }),
).rejects.toMatchObject({ code: ApiCode.AUTH_PERMISSION_DENIED });
expect(jwt.signAsync).not.toHaveBeenCalled();
expect(redis.setEx).not.toHaveBeenCalled();
});
it('verifies credentials before checking policy', async () => {
await expect(
service.exchangeToken({ appId: 'app1', appSecret: 'wrong', user: valid }),
).rejects.toMatchObject({ code: ApiCode.AUTH_INVALID_CREDENTIALS });
expect(jwt.signAsync).not.toHaveBeenCalled();
});
it('issues a normal scoped login', async () => {
await expect(
service.exchangeToken({ appId: 'app1', appSecret: 'test-secret', user: valid }),
).resolves.toMatchObject({ accessToken: 'signed-token' });
expect(jwt.signAsync).toHaveBeenCalledWith(
expect.objectContaining({ role: 'leader', orgScope: ['clinic-a'] }),
expect.anything(),
);
});
test.each([
{ orgScope: [] },
{ role: UserRole.ADMIN },
{ orgScope: ['friday-market'] },
{ tenantId: 'other' },
])('does not renew legacy excessive tokens: %j', async (patch) => {
jwt.verifyAsync.mockResolvedValue({
...valid,
sub: 'u1',
hostId: 'host1',
jti: 'jti1',
...patch,
});
await expect(service.refresh('old')).rejects.toMatchObject({
code: ApiCode.AUTH_PERMISSION_DENIED,
});
expect(jwt.signAsync).not.toHaveBeenCalled();
});
it('refreshes scoped tokens', async () => {
await expect(service.refresh('old')).resolves.toMatchObject({ accessToken: 'signed-token' });
});
it('rejects disabled hosts during refresh', async () => {
prisma.host.findUnique.mockResolvedValue({ ...host, active: false });
await expect(service.refresh('old')).rejects.toMatchObject({
code: ApiCode.AUTH_INVALID_REFRESH_TOKEN,
});
expect(jwt.signAsync).not.toHaveBeenCalled();
});
});
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { checkHostAuthPolicy, loadHostAuthPolicy } from '../src/modules/auth/host-auth-policy';
import { ManifestAuthSchema } from '../src/modules/sync/cold-import/manifest.schema';
const user = { role: 'leader', tenantId: 'friday-market', orgScope: ['clinic-a'] };
const policy = {
auth: ManifestAuthSchema.parse({
require_org_scope: true,
max_role: 'leader',
forbid_root_scope: true,
}),
tenantId: 'friday-market',
};
describe('host auth boundaries', () => {
test.each(['staff', 'leader'])('allows scoped %s', (role) => {
expect(checkHostAuthPolicy(policy, { ...user, role })).toBeNull();
});
test.each([
{ role: 'admin' },
{ role: 'unknown' },
{ orgScope: [] },
{ orgScope: [''] },
{ orgScope: [' '] },
{ orgScope: ['clinic-a', ''] },
{ orgScope: ['friday-market'] },
{ orgScope: ['clinic-a', 'friday-market'] },
{ orgScope: [' friday-market '] },
{ tenantId: 'other', orgScope: ['clinic-a'] },
])('rejects excessive context %j', (patch) => {
expect(checkHostAuthPolicy(policy, { ...user, ...patch })).not.toBeNull();
});
it('permits a brand node, without converting it into the market root', () => {
expect(checkHostAuthPolicy(policy, { ...user, orgScope: ['brand-a'] })).toBeNull();
});
it('preserves unrestricted host defaults including group administrators', () => {
expect(
checkHostAuthPolicy(
{ auth: ManifestAuthSchema.parse({}) },
{ role: 'admin', tenantId: 'ruier-grp', orgScope: [] },
),
).toBeNull();
});
it('rejects malformed policy options', () => {
expect(() => ManifestAuthSchema.parse({ max_role: 'owner' })).toThrow();
expect(() => ManifestAuthSchema.parse({ require_org_scope: 'true' })).toThrow();
expect(() => ManifestAuthSchema.parse({ typo: true })).toThrow();
});
});
describe('server-side manifest loader', () => {
let dir: string;
const original = process.env.PAC_INCREMENTAL_DATA_DIR;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'pac-auth-'));
process.env.PAC_INCREMENTAL_DATA_DIR = dir;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (original === undefined) delete process.env.PAC_INCREMENTAL_DATA_DIR;
else process.env.PAC_INCREMENTAL_DATA_DIR = original;
});
function write(content: string) {
mkdirSync(join(dir, 'friday'), { recursive: true });
writeFileSync(join(dir, 'friday', 'manifest.yaml'), content);
}
it('fails closed when Friday config is missing', () => {
expect(() => loadHostAuthPolicy('friday')).toThrow();
});
it('allows unconfigured hosts as before', () => {
expect(loadHostAuthPolicy('jvs-dw').auth.max_role).toBe('admin');
});
test.each([
'host_name: friday',
'host_name: friday\ntenant_id: friday-market\nauth: {}',
'host_name: different\nauth: {}',
'host_name: friday\ntenant_id: friday-market\nauth:\n unknown: true',
])('rejects incomplete or malformed config', (content) => {
write(content);
expect(() => loadHostAuthPolicy('friday')).toThrow();
});
it('loads the configured policy', () => {
write(
'host_name: friday\ntenant_id: friday-market\nauth:\n require_org_scope: true\n max_role: leader\n forbid_root_scope: true',
);
expect(loadHostAuthPolicy('friday')).toEqual(policy);
});
it('rejects traversal in host name', () => {
expect(() => loadHostAuthPolicy('../friday')).toThrow();
});
it('checks the actual committed Friday configuration', () => {
process.env.PAC_INCREMENTAL_DATA_DIR = join(__dirname, '../data');
expect(loadHostAuthPolicy('friday')).toEqual(policy);
expect(
checkHostAuthPolicy(loadHostAuthPolicy('jvs-dw'), {
role: 'admin',
tenantId: 'ruier-grp',
orgScope: [],
}),
).toBeNull();
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment