Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
1211c4ab
Commit
1211c4ab
authored
Oct 13, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(auth): enforce Friday host token permission boundaries
parent
0b2f7fec
Pipeline
#3781
failed in 0 seconds
Changes
7
Pipelines
2
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
314 additions
and
0 deletions
+314
-0
apps/pac-docs/content/docs/integration/auth-login.mdx
+4
-0
apps/pac-service/data/friday/manifest.yaml
+6
-0
apps/pac-service/src/modules/auth/auth.service.ts
+19
-0
apps/pac-service/src/modules/auth/host-auth-policy.ts
+62
-0
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
+10
-0
apps/pac-service/tests/host-auth-exchange.spec.ts
+109
-0
apps/pac-service/tests/host-auth-policy.spec.ts
+104
-0
No files found.
apps/pac-docs/content/docs/integration/auth-login.mdx
View file @
1211c4ab
...
...
@@ -68,6 +68,10 @@ iframe 内的 token 落地与刷新由 PAC 前端完成。`refreshToken` 过期(
| `user.orgScope` | string[] | ✅ | 数据权限:用户可达的组织节点 id 列表;传 `[]` = 全集团不限。见下「`tenantId` 与 `orgScope`」 |
| `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` 字段本身必传(哪怕是空数组);请求体为严格模式,未定义的字段会被拒绝。
**`role`** — 功能权限:
...
...
apps/pac-service/data/friday/manifest.yaml
View file @
1211c4ab
...
...
@@ -17,6 +17,12 @@
# pnpm sync -- --dir=./data/friday --dry-run # 看翻译效果,不写库
# pnpm sync -- --dir=./data/friday --no-recompute
# 普通宿主换票只允许品牌/诊所范围;跨品牌运营不得复用此入口。
auth
:
require_org_scope
:
true
max_role
:
leader
forbid_root_scope
:
true
host_name
:
friday
display_name
:
FRIDAY SaaS(多品牌市场)
auto_sync
:
false
...
...
apps/pac-service/src/modules/auth/auth.service.ts
View file @
1211c4ab
...
...
@@ -27,6 +27,7 @@ import { AlertService } from '../../common/alerting/alert.service';
import
{
parseDurationToSeconds
}
from
'./duration'
;
import
{
loadMockUsers
}
from
'./mock-users'
;
import
{
OrgTreeService
}
from
'./org-tree'
;
import
{
checkHostAuthPolicy
,
loadHostAuthPolicy
}
from
'./host-auth-policy'
;
const
CODE_PREFIX
=
'pac:exchange-code:'
;
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
...
...
@@ -145,6 +146,8 @@ export class AuthService {
throw
new
BizError
(
ApiCode
.
AUTH_INVALID_CREDENTIALS
);
}
this
.
assertHostAuthPolicy
(
host
.
name
,
req
.
user
);
// 契约 user 上下文 → token,与 mockLogin 共用同一条 issueTokens(展开 + 签发)。
const
{
accessToken
,
refreshToken
}
=
await
this
.
issueTokens
(
host
,
req
.
user
);
...
...
@@ -493,6 +496,10 @@ export class AuthService {
const stored = await this.redis.get(REFRESH_PREFIX + payload.jti);
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 上下文)──
const permissions = this.resolvePermissions(payload.role);
const accessToken = await this.signAccessToken({
...
...
@@ -550,6 +557,18 @@ export class AuthService {
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[] {
return ROLE_PERMISSIONS[role];
}
...
...
apps/pac-service/src/modules/auth/host-auth-policy.ts
0 → 100644
View file @
1211c4ab
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
;
}
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
View file @
1211c4ab
import
{
z
}
from
'zod'
;
import
{
UserRoleSchema
}
from
'@pac/types'
;
import
{
TransformsSchema
}
from
'../transforms/transforms.schema'
;
/**
...
...
@@ -152,10 +153,19 @@ export const DEFAULT_REVERSE_PULL_FROM = [
]
as
const
;
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
.
object
({
/// 关联到 PAC hosts.name(slug,平台必须先 seed/admin 注册)
host_name
:
z
.
string
().
min
(
1
),
auth
:
ManifestAuthSchema
.
optional
(),
display_name
:
z
.
string
().
optional
(),
/// 是否纳入每日自动增量同步(SyncIncrementalSchedulerService 启动时自动发现)。
...
...
apps/pac-service/tests/host-auth-exchange.spec.ts
0 → 100644
View file @
1211c4ab
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
();
});
});
apps/pac-service/tests/host-auth-policy.spec.ts
0 → 100644
View file @
1211c4ab
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
();
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment