adminApi = new AdminApiService(); $this->adminExtra = new AdminExtraApiService(); $this->audit = new AdminAuditService(); } /** * @param array $payload */ protected function auditAuthorized(string $action, array $actor, array $payload = []): void { $payload['actor'] = $this->actorAuditSummary($actor); $this->audit->log($action, $payload); } /** * @param array $actor * * @return array */ private function actorAuditSummary(array $actor): array { return [ 'id_pegawai' => $actor['id_pegawai'] ?? null, 'nip' => $actor['nip'] ?? null, 'nama_lengkap' => $actor['nama_lengkap'] ?? null, ]; } /** * Ringkasan GET/POST untuk payload audit (dibatasi ukuran). * * @return array */ protected function auditRequestParams(): array { $get = $this->request->getGet(); $post = $this->request->getPost(); return [ 'query' => is_array($get) ? $this->truncateParamMap($get, 40) : [], 'post' => is_array($post) ? $this->truncateParamMap($post, 40) : [], ]; } /** * @param array $map * * @return array */ private function truncateParamMap(array $map, int $maxKeys): array { $i = 0; $out = []; foreach ($map as $k => $v) { if ($i++ >= $maxKeys) { $out['_truncated'] = true; break; } if (is_scalar($v) || $v === null) { $out[(string) $k] = $v; } elseif (is_array($v)) { $out[(string) $k] = '[array]'; } } return $out; } /** * @param array $payload */ protected function respond(array $payload, int $code = 200): ResponseInterface { return $this->response->setStatusCode($code)->setJSON($payload); } protected function extractToken(): string { $t = $this->request->getGet('token') ?? $this->request->getPost('token') ?? $this->request->getHeaderLine('X-Admin-Token'); return is_string($t) ? trim($t) : ''; } /** * Sesi admin panel aktif + token permintaan sama dengan `admin_mobile_token` di sesi. * * @return array{actor: array|null, response: null}|array{actor: null, response: ResponseInterface} */ protected function requireAdminSessionBoundToken(): array { $sess = session(); $sessTok = $sess->get('admin_mobile_token'); if (! is_string($sessTok) || $sessTok === '') { $this->logAdminSecurityDenied('no_admin_session', 'unauthorized'); return [ 'actor' => null, 'response' => $this->respond(['status' => 0, 'pesan' => 'Unauthorized'], 401), ]; } $reqTok = $this->extractToken(); if ($reqTok === '' || $reqTok !== $sessTok) { $this->logAdminSecurityDenied('token_not_bound_to_session', 'unauthorized'); return [ 'actor' => null, 'response' => $this->respond(['status' => 0, 'pesan' => 'Unauthorized'], 401), ]; } $actor = $this->adminApi->actorFromToken($reqTok); if ($actor === null) { $this->logAdminSecurityDenied('invalid_token_actor', 'unauthorized'); return [ 'actor' => null, 'response' => $this->respond(['status' => 0, 'pesan' => 'Token tidak valid'], 403), ]; } return ['actor' => $actor, 'response' => null]; } /** * RBAC fitur — memakai `admin_ion_groups` + `Config\AdminAccess` (sama lapisan web). */ protected function requireAdminFeature(string $feature): ?ResponseInterface { if (canAccess($feature)) { return null; } $this->logAdminSecurityDenied('forbidden_feature:' . $feature, 'forbidden'); return $this->respond(['status' => 0, 'pesan' => 'Forbidden'], 403); } /** * Gabungan autentikasi sesi + RBAC satu pintu untuk endpoint admin API. * * @return array{actor: array|null, response: null}|array{actor: null, response: ResponseInterface} */ protected function requireAdminApiAccess(string $feature): array { $auth = $this->requireAdminSessionBoundToken(); if ($auth['response'] !== null) { return $auth; } $deny = $this->requireAdminFeature($feature); if ($deny !== null) { return ['actor' => $auth['actor'], 'response' => $deny]; } return ['actor' => $auth['actor'], 'response' => null]; } /** * Filter cabang untuk grup Ion `supervisor`: data pegawai/cuti/presensi dibatasi ke `pegawai.kantor` * yang sama dengan baris pegawai pemegang token (bukan dari jabatan). * * @return array{scoped: bool, kantor_id: int|null} scoped=true & kantor_id=null → pegawai supervisor belum punya kantor */ protected function cabangScopeFromActor(?array $actor): array { if ($actor === null || ! rbac_enforce_ion()) { return ['scoped' => false, 'kantor_id' => null]; } if (hasRole('webmaster') || hasRole('hrd')) { return ['scoped' => false, 'kantor_id' => null]; } if (! hasRole('supervisor')) { return ['scoped' => false, 'kantor_id' => null]; } $kid = (int) ($actor['kantor'] ?? 0); if ($kid <= 0) { return ['scoped' => true, 'kantor_id' => null]; } return ['scoped' => true, 'kantor_id' => $kid]; } /** * Supervisor wajib punya `kantor` pada baris pegawai (token) agar scope cabang terdefinisi. */ protected function denyIfSupervisorCabangInvalid(array $scope): ?ResponseInterface { if ($scope['scoped'] && $scope['kantor_id'] === null) { return $this->respond([ 'status' => 0, 'pesan' => 'Akun supervisor belum memiliki cabang (kantor) pada data pegawai. Hubungi HRD untuk mengisi kolom kantor.', ], 403); } return null; } /** @return int|null null = tanpa filter cabang */ protected function cabangKantorIdForQueries(array $scope): ?int { return $scope['scoped'] ? $scope['kantor_id'] : null; } /** * Setelah {@see requireAdminApiAccess()} sukses: validasi kantor supervisor & siapkan filter cabang. * * @return array{kid: int|null, response: ResponseInterface|null} */ protected function cabangKantorAfterAuth(?array $actor): array { $scope = $this->cabangScopeFromActor($actor); if (($deny = $this->denyIfSupervisorCabangInvalid($scope)) !== null) { return ['kid' => null, 'response' => $deny]; } return ['kid' => $this->cabangKantorIdForQueries($scope), 'response' => null]; } private function logAdminSecurityDenied(string $reason, string $outcome): void { $ip = $this->request->getIPAddress(); $path = $this->request->getUri()->getPath(); $when = date('c'); log_message('warning', "[api/admin] denied={$reason} outcome={$outcome} ip={$ip} path={$path} at={$when}"); $action = $outcome === 'forbidden' ? 'api.admin.security.forbidden' : 'api.admin.security.unauthorized'; $this->audit->log($action, [ 'reason' => $reason, '__outcome' => $outcome, ]); } }