diff --git a/app/Services/Auth/DingTalkClosedAccess.php b/app/Services/Auth/DingTalkClosedAccess.php index c785bd72..753f0b59 100644 --- a/app/Services/Auth/DingTalkClosedAccess.php +++ b/app/Services/Auth/DingTalkClosedAccess.php @@ -92,7 +92,9 @@ class DingTalkClosedAccess return null; } - $role = Role::where('name', SystemAccess::USER)->firstOrFail(); + $roleName = FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id']) + ? SystemAccess::SUPER_ADMIN : SystemAccess::USER; + $role = Role::where('name', $roleName)->firstOrFail(); $email = $this->normalizeEmail($profile['email'] ?? null); if (! $email || User::withTrashed()->where('email', $email)->exists()) { $email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid'; @@ -112,7 +114,7 @@ class DingTalkClosedAccess 'created_from' => 'dingtalk_roster', ]); $account->roles()->attach($role->id); - // Shell access only; no financial pages or administrative privileges. + // Explicit designation controls the role; ordinary roster users get shell access. $account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id); $account->companies()->attach($companies->first()->id); if ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) { diff --git a/app/Services/Auth/FinanceRoster.php b/app/Services/Auth/FinanceRoster.php index 474555d7..a6ee0c39 100644 --- a/app/Services/Auth/FinanceRoster.php +++ b/app/Services/Auth/FinanceRoster.php @@ -7,6 +7,11 @@ use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory; /** Single-person, current HR authorization lookup; no HR rows or stale grants are cached. */ class FinanceRoster { + public static function isDesignatedSuperAdmin(string $userId): bool + { + return $userId !== '' && in_array($userId, config('dingtalk.super_admin_user_ids', []), true); + } + public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries) { } @@ -77,7 +82,8 @@ class FinanceRoster return null; } $allowed = array_map('strval', config('dingtalk.finance_department_ids', [])); - if (! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) { + if (! self::isDesignatedSuperAdmin($userId) + && ! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) { return null; } diff --git a/config/dingtalk.php b/config/dingtalk.php index 0ef36a9e..6fe50220 100644 --- a/config/dingtalk.php +++ b/config/dingtalk.php @@ -15,6 +15,10 @@ return [ // When omitted, automatic provisioning requires exactly one enabled company. 'company_id' => env('DINGTALK_COMPANY_ID'), + // Explicitly approved enterprise user IDs only; never names or departments. + // Applies the highest role on first provisioning, not on subsequent logins. + 'super_admin_user_ids' => array_values(array_filter(array_map('trim', explode(',', env('DINGTALK_SUPER_ADMIN_USER_IDS', ''))))), + 'authorize_url' => env('DINGTALK_AUTHORIZE_URL', 'https://login.dingtalk.com/oauth2/auth'), 'token_url' => env('DINGTALK_TOKEN_URL', 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'), 'user_url' => env('DINGTALK_USER_URL', 'https://api.dingtalk.com/v1.0/contact/users/me'), diff --git a/docs/钉钉花名册登录.md b/docs/钉钉花名册登录.md index 6a94f5ff..435c2429 100644 --- a/docs/钉钉花名册登录.md +++ b/docs/钉钉花名册登录.md @@ -13,6 +13,8 @@ 已绑定的启用账号继续使用原账号及原权限。姓名、花名、邮箱和手机号不会用于自动认领其他账号。已拒绝、解绑或停用的身份不会通过再次授权自动恢复。 +负责人明确指定的主管理员可通过受保护的运行时配置 `DINGTALK_SUPER_ADMIN_USER_IDS` 登记企业 userid(逗号分隔,默认空)。仅这些精确身份可以不受财务部门范围限制,首次开户赋予 `super_admin`;仍必须通过唯一花名册、在职、非离职、有效组织链和钉钉企业成员核验。既有账号角色不因该配置在登录时被覆盖。移除指定身份会恢复财务部门准入要求,但不直接改写既有角色。配置不得使用姓名、手机号或整部门,也不得把真实身份值提交到仓库。 + 新账号使用钉钉接口返回的姓名、可用邮箱和手机号;邮箱缺失或已被其他账号使用时,生成稳定的 `@dingtalk.invalid` 内部占位地址。系统生成不可预知的随机本地密码,不展示或发送密码,不发送注册邀请。花名册显示名仅作为姓名缺失时的后备。 新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。按负责人最新要求,尚无页面权限配置时默认显示完整业务导航;主管理员随后可在“侧边栏管理”中设置指定用户的业务范围。配置优先级为指定用户、角色、默认完整导航;显式配置为空时没有业务页面,不回退到完整导航。管理员专属操作(包括店铺密码读取)继续单独鉴权。 diff --git a/tests/Feature/Auth/DingTalkLoginTest.php b/tests/Feature/Auth/DingTalkLoginTest.php index f3657056..de9acffc 100644 --- a/tests/Feature/Auth/DingTalkLoginTest.php +++ b/tests/Feature/Auth/DingTalkLoginTest.php @@ -131,6 +131,27 @@ class DingTalkLoginTest extends FeatureTestCase $this->get(route('dashboard', ['company_id' => $this->company->id]))->assertRedirectContains('/workspace/'); } + public function test_explicitly_designated_identity_gets_highest_role_only_on_first_provisioning(): void + { + config(['dingtalk.super_admin_user_ids' => ['ding-user-1']]); + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-designated', 'open-designated')); + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', ['code' => 'fixture', 'state' => $state])); + $created = auth()->user(); + $this->assertNotNull($created); + $this->assertTrue(\App\Utilities\SystemAccess::isSuperAdmin($created)); + $id = $created->id; + $created->syncRoles([\App\Models\Auth\Role::where('name', 'user')->firstOrFail()->id]); + $created->flushCache(); + auth()->logout(); + session()->invalidate(); + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', ['code' => 'fixture-again', 'state' => $state])); + $this->assertSame($id, auth()->id()); + $this->assertFalse(\App\Utilities\SystemAccess::isSuperAdmin(auth()->user())); + } + public function test_shared_mobile_does_not_claim_an_existing_account(): void { Http::preventStrayRequests(); diff --git a/tests/Unit/Auth/FinanceRosterTest.php b/tests/Unit/Auth/FinanceRosterTest.php index 99016eb8..0a30e0aa 100644 --- a/tests/Unit/Auth/FinanceRosterTest.php +++ b/tests/Unit/Auth/FinanceRosterTest.php @@ -15,6 +15,7 @@ class FinanceRosterTest extends TestCase public function test_roster_authorization(array $overrides, bool $expected): void { config(['dingtalk.finance_department_ids' => ['22848291', '994080505']]); + config(['dingtalk.super_admin_user_ids' => $overrides['designated_ids'] ?? []]); $data = array_replace([ 'flowers' => [['Id' => 123, 'name' => '测试员工', 'flowerName' => '测试花名', 'deptId' => '55248182', 'status' => '', 'empStatus' => '正式']], 'employees' => [['active' => 1, 'dept_id_list' => '[55248182]']], @@ -49,6 +50,11 @@ class FinanceRosterTest extends TestCase public static function rosterCases(): array { return [ + '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], + 'designated administrator cannot bypass resignation' => [['designated_ids' => ['ding-fixture-id'], 'resignation' => ['userid' => 'ding-fixture-id']], false], + 'designated administrator needs roster identity' => [['designated_ids' => ['ding-fixture-id'], 'flowers' => []], false], 'finance descendant' => [[], true], 'second finance root' => [['ancestors' => [['Id' => '994080505', 'parent_id' => '994121400', 'depth' => 1]]], true], 'concurrent finance membership' => [['employees' => [['active' => 1, 'dept_id_list' => '[100,55248182]']]], true],