diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index bb7e98d4..907e47df 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -36,6 +36,7 @@ class Kernel extends HttpKernel
'cookies.response',
'session.start',
\App\Http\Middleware\LogoutIfDingTalkBindingRevoked::class,
+ \App\Http\Middleware\EnsureLimitedDingTalkPageAccess::class,
// 'session.auth',
'session.errors',
'csrf',
diff --git a/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php b/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php
new file mode 100644
index 00000000..3fee8f79
--- /dev/null
+++ b/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php
@@ -0,0 +1,47 @@
+user();
+ $route = $request->route();
+ if (! $user || ! $route?->hasParameter('company_id') || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
+ return $next($request);
+ }
+
+ // Keep the application entrance, sign-out and existing self-profile checks.
+ if ($route->named('dashboard', 'logout', 'profile.edit', 'profile.update')) {
+ return $next($request);
+ }
+
+ $allowed = SidebarPermissions::allowedKeys($user);
+ $key = $route->parameter('section').'/'.$route->parameter('page');
+ if (in_array($key, $allowed, true)) {
+ return $next($request);
+ }
+
+ // The section and quote group are navigation containers for the three pages.
+ if ($route->named('business.pages.show') && $route->parameter('section') === 'courier-payables'
+ && in_array($route->parameter('page'), [null, 'courier-quote'], true)) {
+ return $next($request);
+ }
+
+ // Dedicated detail, file and action routes declare their owning business page.
+ foreach ($route->gatherMiddleware() as $middleware) {
+ if (is_string($middleware) && str_starts_with($middleware, 'business.page:')
+ && in_array(str_replace(',', '/', substr($middleware, strlen('business.page:'))), $allowed, true)) {
+ return $next($request);
+ }
+ }
+
+ // Also deny legacy exports/previews that do not declare a page permission.
+ abort(403, '当前账号没有该页面权限。');
+ }
+}
diff --git a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
index a06d19b3..009d72be 100644
--- a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
+++ b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
@@ -15,7 +15,7 @@ class LogoutIfDingTalkBindingRevoked
$identityId = $request->session()->get('dingtalk_identity_id');
$user = $request->user();
if (! $identityId) {
- if (! $user || $user->created_from !== 'dingtalk_roster') {
+ if (! $user || ! in_array($user->created_from, ['dingtalk_roster', 'dingtalk_roster_limited'], true)) {
return $next($request);
}
// A password reset must not bypass HR eligibility for provisioned
diff --git a/app/Services/Auth/DingTalkClosedAccess.php b/app/Services/Auth/DingTalkClosedAccess.php
index 753f0b59..dd8ad949 100644
--- a/app/Services/Auth/DingTalkClosedAccess.php
+++ b/app/Services/Auth/DingTalkClosedAccess.php
@@ -111,7 +111,8 @@ class DingTalkClosedAccess
'enabled' => true,
'landing_page' => 'dashboard',
'created_by' => 0,
- 'created_from' => 'dingtalk_roster',
+ 'created_from' => FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
+ ? 'dingtalk_roster_limited' : 'dingtalk_roster',
]);
$account->roles()->attach($role->id);
// Explicit designation controls the role; ordinary roster users get shell access.
diff --git a/app/Services/Auth/FinanceRoster.php b/app/Services/Auth/FinanceRoster.php
index c106b27b..ee8deae1 100644
--- a/app/Services/Auth/FinanceRoster.php
+++ b/app/Services/Auth/FinanceRoster.php
@@ -12,6 +12,11 @@ class FinanceRoster
return $userId !== '' && in_array($userId, config('dingtalk.super_admin_user_ids', []), true);
}
+ public static function isLimitedLoginUser(string $userId): bool
+ {
+ return $userId !== '' && in_array($userId, config('dingtalk.limited_login_user_ids', []), true);
+ }
+
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
}
@@ -83,6 +88,7 @@ class FinanceRoster
}
$allowed = array_map('strval', config('dingtalk.finance_department_ids', []));
if (! self::isDesignatedSuperAdmin($userId)
+ && ! self::isLimitedLoginUser($userId)
&& ! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) {
return null;
}
diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php
index 5edcaa50..297b93ce 100644
--- a/app/Utilities/SidebarPermissions.php
+++ b/app/Utilities/SidebarPermissions.php
@@ -4,6 +4,7 @@ namespace App\Utilities;
use App\Models\Auth\User;
use App\Models\Auth\UserAccessGroup;
+use App\Services\Auth\FinanceRoster;
class SidebarPermissions
{
@@ -41,6 +42,11 @@ class SidebarPermissions
public static function allowedKeys(User $user): ?array
{
+ // Limited identities cannot inherit full navigation from roles, groups or user overrides.
+ if (self::hasLimitedDingTalkAccess($user)) {
+ return config('dingtalk.limited_login_page_keys', []);
+ }
+
if (SystemAccess::isSuperAdmin($user)) {
return null;
}
@@ -67,6 +73,13 @@ class SidebarPermissions
return self::normalizeAllowedKeys((array) ($settings['subjects'][$subject] ?? []));
}
+ public static function hasLimitedDingTalkAccess(User $user): bool
+ {
+ return $user->created_from === 'dingtalk_roster_limited'
+ || (config('dingtalk.limited_login_user_ids', []) !== []
+ && FinanceRoster::isLimitedLoginUser((string) $user->dingtalkIdentity?->dingtalk_user_id));
+ }
+
public static function navigationFor(User $user): array
{
$navigation = BusinessNavigation::all();
diff --git a/config/dingtalk.php b/config/dingtalk.php
index 6fe50220..eda77691 100644
--- a/config/dingtalk.php
+++ b/config/dingtalk.php
@@ -12,6 +12,14 @@ return [
// Verified HR roots: 简尚财务中心 and 龙晨品牌中心/财务中心.
'finance_department_ids' => array_values(array_filter(array_map('trim', explode(',', env('DINGTALK_FINANCE_DEPARTMENT_IDS', '22848291,994080505'))))),
+ // Individually approved, current enterprise members; never names or whole departments.
+ // This grants ordinary login only, with the exact courier page scope below.
+ 'limited_login_user_ids' => array_values(array_filter(array_map('trim', explode(',', env('DINGTALK_LIMITED_LOGIN_USER_IDS', ''))))),
+ 'limited_login_page_keys' => [
+ 'courier-payables/courier-onboarding',
+ 'courier-payables/courier-information',
+ 'courier-payables/quote-management',
+ ],
// When omitted, automatic provisioning requires exactly one enabled company.
'company_id' => env('DINGTALK_COMPANY_ID'),
diff --git a/docs/钉钉花名册登录.md b/docs/钉钉花名册登录.md
index 435c2429..e3b32c2b 100644
--- a/docs/钉钉花名册登录.md
+++ b/docs/钉钉花名册登录.md
@@ -15,6 +15,18 @@
负责人明确指定的主管理员可通过受保护的运行时配置 `DINGTALK_SUPER_ADMIN_USER_IDS` 登记企业 userid(逗号分隔,默认空)。仅这些精确身份可以不受财务部门范围限制,首次开户赋予 `super_admin`;仍必须通过唯一花名册、在职、非离职、有效组织链和钉钉企业成员核验。既有账号角色不因该配置在登录时被覆盖。移除指定身份会恢复财务部门准入要求,但不直接改写既有角色。配置不得使用姓名、手机号或整部门,也不得把真实身份值提交到仓库。
+## 单独授权的受限登录
+
+负责人批准的非财务人员通过服务器私有配置 `DINGTALK_LIMITED_LOGIN_USER_IDS` 登记精确企业 userid,逗号分隔、默认空。姓名和花名只用于人工核对,不参与授权,不扩大同部门其他人员的登录范围。真实身份值不得提交到仓库。
+
+这些人员仅跳过财务部门归属限制,仍须满足唯一花名册、在职、无离职记录、启用、有效组织链和钉钉企业成员校验。首次开户沿用普通 `user` 角色,不使用主管理员名单,也不自动分配审核人或管理员权限。
+
+页面范围固定为 `courier-payables/courier-onboarding`、`courier-payables/courier-information`、`courier-payables/quote-management`。菜单、标签、页面及其详情、附件和操作接口共用此范围;不继承角色、访问组或用户配置中的更宽权限,不扩展到“终版”目录。受限会话对带公司范围的网页路由统一校验,只保留应用入口、退出、原有本人资料校验以及这三个页面和对应接口;其他页面、旧导出、预览和通用附件入口直接返回 403,避免遗漏旧路由。三个页面内部已有操作仍按各自现有权限校验。
+
+配置移除后,下次登录及最长 5 分钟会话复核恢复财务组织范围要求。新受限账号保留 `created_from=dingtalk_roster_limited` 标识,移除配置不会在会话复核前回退到完整导航;密码登录同样核验当前花名册。发布时仅备份并更新该私有配置项,重建配置缓存;不手工代建账号、不伪造扫码、不改写人员资料或业务数据。上线后使用真实身份只读检查准入和精确页面范围,首次真实扫码仍由本人完成。
+
+## 普通账号与权限
+
新账号使用钉钉接口返回的姓名、可用邮箱和手机号;邮箱缺失或已被其他账号使用时,生成稳定的 `@dingtalk.invalid` 内部占位地址。系统生成不可预知的随机本地密码,不展示或发送密码,不发送注册邀请。花名册显示名仅作为姓名缺失时的后备。
新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。按负责人最新要求,尚无页面权限配置时默认显示完整业务导航;主管理员随后可在“侧边栏管理”中设置指定用户的业务范围。配置优先级为指定用户、角色、默认完整导航;显式配置为空时没有业务页面,不回退到完整导航。管理员专属操作(包括店铺密码读取)继续单独鉴权。
diff --git a/resources/lang/zh-CN/auth.php b/resources/lang/zh-CN/auth.php
index 620925e8..c8b3fab9 100644
--- a/resources/lang/zh-CN/auth.php
+++ b/resources/lang/zh-CN/auth.php
@@ -58,7 +58,7 @@ return [
'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。',
'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。',
'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。',
- 'outside_roster' => '当前仅向花名册中财务中心及其下级部门的在职人员开放。如人员资料有误,请联系管理员核对花名册。',
+ 'outside_roster' => '当前仅向财务中心及其下级部门的在职人员,以及已单独授权的本公司在职人员开放。如有疑问,请联系管理员核对登录资格。',
'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。',
],
diff --git a/resources/views/system_settings/dingtalk_bindings/index.blade.php b/resources/views/system_settings/dingtalk_bindings/index.blade.php
index 91576a3d..4f57e735 100644
--- a/resources/views/system_settings/dingtalk_bindings/index.blade.php
+++ b/resources/views/system_settings/dingtalk_bindings/index.blade.php
@@ -6,7 +6,7 @@
@include('system_settings.partials.navigation')
- 花名册中两个财务中心及其下级部门的在职人员,首次钉钉授权后会自动创建普通账号并登录,无需另行注册。业务权限由系统管理员分配;已拒绝、解绑或停用的账号不会自动恢复。
+ 花名册中两个财务中心及其下级部门的在职人员,以及已单独授权的本公司在职人员,首次钉钉授权后会自动创建普通账号并登录,无需另行注册。单独授权人员仅可访问指定的快递入驻、快递资料和报价管理页面;已拒绝、解绑或停用的账号不会自动恢复。
@if ($errors->any())
diff --git a/tests/Feature/Auth/DingTalkLoginTest.php b/tests/Feature/Auth/DingTalkLoginTest.php
index de9acffc..18760a4e 100644
--- a/tests/Feature/Auth/DingTalkLoginTest.php
+++ b/tests/Feature/Auth/DingTalkLoginTest.php
@@ -33,6 +33,7 @@ class DingTalkLoginTest extends FeatureTestCase
'dingtalk.app_token_url' => 'https://oapi.dingtalk.test/gettoken',
'dingtalk.user_by_union_id_url' => 'https://oapi.dingtalk.test/topapi/user/getbyunionid',
'dingtalk.user_detail_url' => 'https://oapi.dingtalk.test/topapi/v2/user/get',
+ 'dingtalk.limited_login_user_ids' => [],
]);
app('url')->forceRootUrl(config('app.url'));
app('url')->forceScheme('https');
@@ -131,6 +132,104 @@ class DingTalkLoginTest extends FeatureTestCase
$this->get(route('dashboard', ['company_id' => $this->company->id]))->assertRedirectContains('/workspace/');
}
+ public function test_limited_login_gets_only_three_courier_pages_and_blocks_direct_urls(): void
+ {
+ config(['dingtalk.limited_login_user_ids' => ['ding-user-1']]);
+ Http::preventStrayRequests();
+ Http::fake($this->dingtalkResponses('union-limited', 'open-limited'));
+ $state = $this->startAuthorization();
+ $this->get(route('login.dingtalk.callback', ['code' => 'fixture', 'state' => $state]))
+ ->assertRedirect(route('dashboard', ['company_id' => $this->company->id]));
+ $this->get(route('dashboard', ['company_id' => $this->company->id]))
+ ->assertRedirectContains('/workspace/courier-payables/courier-quote');
+
+ $created = auth()->user();
+ $this->assertSame('dingtalk_roster_limited', $created->created_from);
+ $this->assertTrue($created->hasRole('user'));
+ $this->assertFalse($created->hasRole('admin'));
+ $this->assertFalse($created->hasRole('super_admin'));
+ $keys = [
+ 'courier-payables/courier-onboarding',
+ 'courier-payables/courier-information',
+ 'courier-payables/quote-management',
+ ];
+ $this->assertSame($keys, SidebarPermissions::allowedKeys($created));
+ $navigation = SidebarPermissions::navigationFor($created);
+ $this->assertSame(['courier-payables'], array_column($navigation, 'slug'));
+ $children = collect($navigation[0]['pages'])->flatMap(fn ($page) => $page['children'] ?? [$page]);
+ $this->assertEqualsCanonicalizing(['courier-onboarding', 'courier-information', 'quote-management'], $children->pluck('slug')->all());
+
+ // Broader role/user grants must not silently expand this person's agreed scope.
+ setting()->set('system.sidebar_permissions', [
+ 'configured' => ['role_user', 'user_'.$created->id],
+ 'subjects' => ['role_user' => ['company-management'], 'user_'.$created->id => ['courier-payables']],
+ ]);
+ $this->assertSame($keys, SidebarPermissions::allowedKeys($created));
+
+ $this->withExceptionHandling();
+ foreach ([
+ ['company-management', 'basic-information', 403],
+ ['courier-payables', 'agreement-signing', 403],
+ ['courier-payables', 'shipping-data', 403],
+ ['courier-payables', 'freight-reconciliation', 403],
+ ['courier-payables-final', 'courier-information', 403],
+ ] as [$section, $page, $status]) {
+ $this->get(route('business.pages.show', ['company_id' => $this->company->id, 'section' => $section, 'page' => $page]))
+ ->assertStatus($status);
+ }
+ $this->get(route('system-settings.sidebar.edit'))->assertForbidden();
+ $this->get(route('business.courier-payables.shipping-data.export', ['company_id' => $this->company->id]))->assertForbidden();
+ // These older endpoints only have a general read permission, not a business.page guard.
+ foreach ([
+ 'workspace/product-inventory/monthly-weighted-average-price/export',
+ 'workspace/product-inventory/inventory-report/export',
+ 'preview/invoices/1',
+ 'uploads/1/download',
+ ] as $path) {
+ $this->get(url($this->company->id.'/'.$path))->assertForbidden();
+ }
+
+ foreach (['courier-onboarding', 'courier-information', 'quote-management'] as $page) {
+ $request = \Illuminate\Http\Request::create('/');
+ $request->setUserResolver(fn () => $created);
+ $response = app(\App\Http\Middleware\EnsureBusinessPageAccess::class)->handle(
+ $request, fn () => response('allowed'), 'courier-payables', $page
+ );
+ $this->assertSame('allowed', $response->getContent());
+ }
+
+ $this->mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, function ($mock): void {
+ $mock->shouldReceive('relatedAvailability')->andReturn([
+ 'main' => false, 'networks' => false, 'accounts' => false,
+ 'network_ready' => false, 'account_ready' => false,
+ ]);
+ $mock->shouldReceive('available')->andReturnFalse();
+ });
+ foreach (['courier-onboarding' => '快递入驻', 'courier-information' => '快递资料', 'quote-management' => '报价管理'] as $page => $title) {
+ $this->get(route('business.pages.show', ['company_id' => $this->company->id, 'section' => 'courier-payables', 'page' => $page]))
+ ->assertOk()->assertSeeText($title)
+ ->assertDontSee(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'shipping-data']), false)
+ ->assertDontSee(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']), false);
+ }
+
+ // Revoking the login exception must never widen the existing session's page scope.
+ config(['dingtalk.limited_login_user_ids' => []]);
+ $this->assertSame($keys, SidebarPermissions::allowedKeys($created));
+ $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->with('ding-user-1')->andReturnNull();
+ $this->withSession(['dingtalk_roster_checked_at' => now()->subMinutes(6)->timestamp])
+ ->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
+ $this->assertGuest();
+ }
+
+ public function test_limited_password_login_still_checks_current_roster(): void
+ {
+ $this->user->update(['created_from' => 'dingtalk_roster_limited']);
+ DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-limited-password', 'dingtalk_user_id' => 'ding-limited-password']);
+ $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->with('ding-limited-password')->andReturnNull();
+ $this->actingAs($this->user)->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
+ $this->assertGuest();
+ }
+
public function test_explicitly_designated_identity_gets_highest_role_only_on_first_provisioning(): void
{
config(['dingtalk.super_admin_user_ids' => ['ding-user-1']]);
diff --git a/tests/Unit/Auth/FinanceRosterTest.php b/tests/Unit/Auth/FinanceRosterTest.php
index c55d6cd3..72fa4ca5 100644
--- a/tests/Unit/Auth/FinanceRosterTest.php
+++ b/tests/Unit/Auth/FinanceRosterTest.php
@@ -30,6 +30,7 @@ class FinanceRosterTest extends TestCase
{
config(['dingtalk.finance_department_ids' => ['22848291', '994080505']]);
config(['dingtalk.super_admin_user_ids' => $overrides['designated_ids'] ?? []]);
+ config(['dingtalk.limited_login_user_ids' => $overrides['limited_ids'] ?? []]);
$data = array_replace([
'flowers' => [['Id' => 123, 'name' => '测试员工', 'flowerName' => '测试花名', 'deptId' => '55248182', 'status' => '', 'empStatus' => '正式']],
'employees' => [['active' => 1, 'dept_id_list' => '[55248182]']],
@@ -64,6 +65,14 @@ class FinanceRosterTest extends TestCase
public static function rosterCases(): array
{
return [
+ 'limited current employee outside finance' => [['limited_ids' => ['ding-fixture-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '1', 'depth' => 0]]], true],
+ 'limited grant is exact identity only' => [['limited_ids' => ['another-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '1', 'depth' => 0]]], false],
+ 'limited grant still requires unique roster identity' => [['limited_ids' => ['ding-fixture-id'], 'flowers' => []], false],
+ 'limited grant rejects duplicate roster identity' => [['limited_ids' => ['ding-fixture-id'], 'flowers' => [['Id' => 1], ['Id' => 2]]], false],
+ 'limited grant cannot bypass resignation' => [['limited_ids' => ['ding-fixture-id'], 'resignation' => ['userid' => 'ding-fixture-id']], false],
+ 'limited grant cannot bypass disabled employment' => [['limited_ids' => ['ding-fixture-id'], 'employees' => [['active' => 0]]], false],
+ 'limited grant cannot bypass resigned registration' => [['limited_ids' => ['ding-fixture-id'], 'registrations' => [['employment_status' => '5']]], false],
+ 'limited grant cannot bypass broken organization' => [['limited_ids' => ['ding-fixture-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '55248182', 'depth' => 20]]], false],
'designated active administrator outside finance' => [['designated_ids' => ['ding-fixture-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '1', 'depth' => 0]]], true],
'designation does not cover another identity' => [['designated_ids' => ['another-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '1', 'depth' => 0]]], false],
'designated administrator still requires active employment' => [['designated_ids' => ['ding-fixture-id'], 'employees' => [['active' => 0]]], false],