1,'company_id'=>2,'unit_id'=>2],403); return $p; } public static function fingerprint(array $p):string { return hash('sha256',json_encode(array_intersect_key($p,array_flip(['version','scope','profiles','existing_stock_profile','new_right_names','financial_source_policy'])),JSON_THROW_ON_ERROR)); } public function install():array { CompositionSchema::identity();$p=self::policy();$pin=self::fingerprint($p);$scope=$p['scope']; $known=[]; foreach([base_path('app/Modules/Services/DocumentPreview.php'),base_path('app/Modules/Purchasing/ReceiptRuntime.php'),...glob(database_path('migrations/*.php'))] as $file){preg_match_all("/'([a-z_]+\\.[a-z_.]+)'/",file_get_contents($file),$matches);$known=[...$known,...$matches[1]];} foreach(\App\Modules\Services\ServicePermissions::AREAS as $area)foreach(\App\Modules\Services\ServicePermissions::ACTIONS as $action)$known[]=$area.'.'.$action; foreach(['view','prepare','validate','post','correct_prepare','correct_validate','correct_post','period_prepare','period_validate','period_close'] as $action)$known[]='general_ledger.'.$action; foreach(['view','prepare','confirm'] as $action)$known[]='workshop_stock.'.$action; $known=[...$known,'stock.view','stock.operations.prepare','stock.operations.confirm']; foreach($p['profiles'] as $profile)foreach($profile['rights'] as $right)abort_unless(in_array($right['permission'],$known,true),422,'Direito fora do catálogo existente/aprovado: '.$right['permission']); foreach($p['existing_stock_profile']['rights'] as $right)abort_unless(in_array($right,$known,true),422); return DB::transaction(function()use($p,$pin,$scope){ DB::selectOne("SELECT pg_advisory_xact_lock(hashtext('sgf-approved-presentation-profiles'))"); DB::table('companies')->where('id',$scope['company_id'])->where('tenant_id',$scope['tenant_id'])->lockForUpdate()->firstOrFail(); abort_unless(DB::table('units')->where(['tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'id'=>$scope['unit_id']])->exists(),403); if(!DB::getSchemaBuilder()->hasTable('sgf_presentation_profiles')){ DB::unprepared('CREATE TABLE sgf_presentation_profiles(profile_key varchar(80) PRIMARY KEY,user_id bigint NOT NULL UNIQUE REFERENCES users(id),tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,label text NOT NULL,source_hash char(64) NOT NULL,created_at timestamptz NOT NULL DEFAULT now())'); DB::statement('REVOKE ALL ON TABLE sgf_presentation_profiles FROM PUBLIC'); DB::statement('CREATE TRIGGER sgf_presentation_profiles_append_only BEFORE UPDATE OR DELETE ON sgf_presentation_profiles FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()'); } $existing=DB::table('sgf_presentation_profiles')->get(); if($existing->isNotEmpty()){ abort_unless($existing->count()===count($p['profiles'])&&$existing->every(fn($v)=>hash_equals($v->source_hash,$pin)),409,'A matriz de perfis mudou; não ampliar acessos por inferência.'); return ['profiles'=>$existing->keyBy('profile_key')->map(fn($r)=>['user_id'=>(int)$r->user_id,'label'=>$r->label])->all(),'replayed'=>true,'source_hash'=>$pin]; } $company=[];$unit=[]; foreach($p['profiles'] as $profile)foreach($profile['rights'] as $r){ abort_unless(preg_match('/^[a-z][a-z_.]+$/D',$r['permission'])&&in_array($r['scope'],['company','unit'],true),422); if($r['scope']==='company')$company[]=$r['permission'];else $unit[]=$r['permission']; } $unit=[...$unit,...$p['existing_stock_profile']['rights']]; $old=DB::selectOne("SELECT pg_get_constraintdef(oid) value FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->value; if(!preg_match('/^CHECK \((.*)\)$/s',$old,$m))throw new \LogicException('Permission schema changed'); $literal=fn($values)=>implode(',',array_map(fn($v)=>"'".$v."'",array_unique($values))); DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check'); DB::statement('ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (('.$m[1].') OR (unit_id IS NULL AND permission IN ('.$literal($company).')) OR (unit_id IS NOT NULL AND permission IN ('.$literal($unit).')))'); $profiles=[]; foreach($p['profiles'] as $profile){ abort_if(User::where('email','sgf-'.$profile['id'].'-'.CompositionRuntime::physical()['composition'].'@example.test')->exists(),409,'Conta existente não registada nesta matriz.'); $user=User::create(['name'=>$profile['label'],'email'=>'sgf-'.$profile['id'].'-'.CompositionRuntime::physical()['composition'].'@example.test','password'=>bin2hex(random_bytes(32))]); DB::table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id']]); foreach($profile['rights'] as $right)$this->grant($user->id,$scope,$right['permission'],$right['scope']==='unit'); DB::table('sgf_presentation_profiles')->insert(['profile_key'=>$profile['id'],'user_id'=>$user->id]+$scope+['label'=>$profile['label'],'source_hash'=>$pin]); Audit::record('access.synthetic_profile_created',null,$scope['tenant_id'],$scope['company_id'],$scope['unit_id'],$user->id,['operator'=>'Autorização expressa do proprietário em10/11/2026','reason'=>'Perfil exclusivamente sintético da matriz aprovada.']); $profiles[$profile['id']]=['user_id'=>$user->id,'label'=>$profile['label']]; } $stock=(int)$p['existing_stock_profile']['user_id']; abort_unless(DB::table('company_memberships')->where(['user_id'=>$stock,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id']])->exists(),403); foreach($p['existing_stock_profile']['rights'] as $right)$this->grant($stock,$scope,$right,true); return ['profiles'=>$profiles,'replayed'=>false,'source_hash'=>$pin]; }); } private function grant(int $user,array $scope,string $right,bool $unit):void { $values=['tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'unit_id'=>$unit?$scope['unit_id']:null,'user_id'=>$user,'permission'=>$right]; if(DB::table('permission_grants')->where($values)->exists())return; DB::table('permission_grants')->insert($values); Audit::record('access.granted_on_creation',null,$scope['tenant_id'],$scope['company_id'],$values['unit_id'],$user,['permission'=>$right,'operator'=>'Autorização10/11/2026','reason'=>'Apenas perfil QA e âmbito da matriz aprovada; sem acesso real.']); } }