270 lines
8.7 KiB
PHP
270 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controllers\Api\Admin;
|
|
|
|
use App\Controllers\BaseController;
|
|
use App\Services\Admin\AdminApiService;
|
|
use App\Services\Admin\AdminExtraApiService;
|
|
use App\Services\AdminAuditService;
|
|
use CodeIgniter\HTTP\RequestInterface;
|
|
use CodeIgniter\HTTP\ResponseInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* Dasar API `/api/admin/*`: wajib **sesi panel admin** + token yang sama dengan sesi;
|
|
* RBAC per fitur (`canAccess`). Tanpa cookie sesi, panggilan hanya dengan `?token=` ditolak.
|
|
*
|
|
* Audit: setelah otorisasi sukses, controller anak memanggil {@see auditAuthorized()}.
|
|
* Percobaan ditolak dicatat ke DB + `log_message`.
|
|
*/
|
|
abstract class BaseAdminApiController extends BaseController
|
|
{
|
|
protected AdminApiService $adminApi;
|
|
|
|
protected AdminExtraApiService $adminExtra;
|
|
|
|
private AdminAuditService $audit;
|
|
|
|
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger): void
|
|
{
|
|
parent::initController($request, $response, $logger);
|
|
helper('rbac');
|
|
$this->adminApi = new AdminApiService();
|
|
$this->adminExtra = new AdminExtraApiService();
|
|
$this->audit = new AdminAuditService();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
protected function auditAuthorized(string $action, array $actor, array $payload = []): void
|
|
{
|
|
$payload['actor'] = $this->actorAuditSummary($actor);
|
|
$this->audit->log($action, $payload);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $actor
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed> $map
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed> $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<string, mixed>|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<string, mixed>|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,
|
|
]);
|
|
}
|
|
}
|