Browse Source

feat: implement Service Tokens for microservice Bearer token management

- Add migration: create service_tokens table (UUID PK, name, service, token_prefix, token_hash, is_active, expires_at, created_by, last_used_at)
- Add Model: ServiceToken with generate(), verify(), active/expired scopes
- Add Service: ServiceTokenService with full CRUD + audit log integration
- Add API Controller: GET/POST/DELETE /api/v1/service-tokens/* (9 endpoints)
- Update ApiClientController: add web actions (store/revoke/regenerate/delete)
- Update routes/api.php: register service token REST endpoints
- Update routes/web.php: register service token web routes
- Update api-clients/index.blade.php: add Create form + Service Tokens table with Regenerate/Revoke/Delete actions
- Update context-index.md: document Service Tokens architecture, schema, endpoints
- Update perintah.md: sync log

Zero impact on existing GeoNetAgent agents (separate geonetagent_dev database).
master
Budi Setiawan 3 weeks ago
parent
commit
ece7b314a4
  1. 467
      server-connection/.cursor/context-index.md
  2. 289
      server-connection/geonet-console/app/Http/Controllers/Api/V1/ServiceTokenController.php
  3. 84
      server-connection/geonet-console/app/Http/Controllers/ApiClientController.php
  4. 134
      server-connection/geonet-console/app/Models/ServiceToken.php
  5. 228
      server-connection/geonet-console/app/Services/ServiceTokenService.php
  6. 39
      server-connection/geonet-console/database/migrations/2026_06_23_130000_create_service_tokens_table.php
  7. 98
      server-connection/geonet-console/resources/views/api-clients/index.blade.php
  8. 21
      server-connection/geonet-console/routes/api.php
  9. 6
      server-connection/geonet-console/routes/web.php
  10. 149
      server-connection/perintah.md

467
server-connection/.cursor/context-index.md

@ -0,0 +1,467 @@
# Context Index (AI Entry Point)
> **Versi Dokumen:** 1.0.0
> **Terakhir Diperbarui:** 2026-06-23
> **Status:** Aktif
> **Pemilik:** Tim Pengembang GeoNet / GIS Portal
---
# Tujuan
Dokumen ini merupakan **titik masuk utama (Single Entry Point)** bagi seluruh AI Agent dan Developer untuk repository **server-connection**.
Setiap AI **WAJIB** membaca dokumen ini terlebih dahulu sebelum melakukan analisis, implementasi, perubahan kode, refactoring, penghapusan, deployment, maupun pembuatan dokumentasi.
Dokumen ini **bukan** tempat menyimpan seluruh informasi proyek, melainkan **peta navigasi** menuju seluruh dokumentasi yang tersedia.
---
# Filosofi Proyek
Proyek ini menerapkan prinsip **AI Native Development**.
Artinya:
* Dokumentasi adalah bagian dari source code.
* Setiap perubahan wajib terdokumentasi.
* Repository merupakan sumber kebenaran utama (Single Source of Truth).
* AI tidak boleh mengandalkan riwayat percakapan.
* Seluruh pengetahuan proyek harus tersimpan dalam repository.
* Developer sering berpindah perangkat (PC, Laptop, Workstation) — AI Agent harus tetap bisa melanjutkan tanpa riwayat chat.
---
# Aturan Utama AI
Sebelum melakukan pekerjaan apa pun, AI wajib:
1. Membaca file ini (`context-index.md`).
2. Membaca dokumentasi yang berkaitan dengan tugas.
3. Memahami arsitektur yang ada.
4. Memastikan tidak ada konflik dengan keputusan arsitektur sebelumnya.
5. Memperbarui dokumentasi setelah implementasi selesai.
6. Mengikuti protokol sync di `perintah.md` sebelum menyatakan pekerjaan selesai.
Implementasi tanpa dokumentasi dianggap **belum selesai**.
---
# Urutan Membaca Dokumentasi
AI wajib membaca dokumen sesuai urutan berikut:
1. `context-index.md` (file ini)
2. `me.md` — Profil & infrastruktur
3. `perintah.md` — Protokol sync dokumentasi
4. Dokumen spesifik modul (lihat tabel Struktur Dokumentasi di bawah)
5. Rules yang relevan di `.cursor/rules/`
Jangan membaca seluruh repository apabila tidak diperlukan.
---
# Struktur Dokumentasi
## 1. Profil & Infrastruktur
**Lokasi:** `.cursor/me.md`
**Berisi:**
* Profil user (IT end-to-end: network, server, storage, GIS, licensing)
* Stack & aset yang sudah dimiliki (Laravel 12, FastAPI, Python, SQL Server, PostgreSQL)
* Monitoring & Identity (Zabbix, Windows Server 2012 R2 AD)
* Compute & Virtualization (Proxmox, VM Database, VM Docker, VM Ollama)
* Network & Storage (MikroTik CCR2004, QNAP TS-932X)
* Endpoint (laptop user, GPO deploy)
* Lingkungan development (OS dev, Docker lokal, deploy, SSH)
* Ruang lingkup tanggung jawab
* Preferensi kerja (Bahasa Indonesia, jangan duplikasi Zabbix, PostgreSQL untuk baru)
* Aplikasi di repo `server-connection` (geonet-console, databaselist, sqlservercheck)
* URL operasional
* File konteks terkait
* Rules Cursor (prioritas)
* Status proyek
* Deploy cepat
**Ringkasan Profil User:**
User adalah **IT Administrator** yang mengelola infrastruktur end-to-end:
- **Network & Security:** MikroTik CCR2004 (gateway, routing, firewall)
- **Compute & Virtualization:** Proxmox (VM host), PostgreSQL/SQL Server (database), Docker (containers)
- **Identity:** Windows Server 2012 R2 Active Directory (domain user management, GPO deploy)
- **Storage:** QNAP TS-932X (NAS SMB)
- **Monitoring:** Zabbix (alerting real-time)
- **Endpoint:** Banyak laptop user (Windows, domain-joined)
- **GIS & Licensing:** Software licensing tracking
**Preferensi Kerja:**
- Bahasa komunikasi: **Bahasa Indonesia**
- Jangan duplikasi fungsi Zabbix — Zabbix tetap pusat alerting real-time
- PostgreSQL untuk aplikasi/CMDB baru; SQL Server untuk legacy/GIS
- Minimalkan scope perubahan; ikuti konvensi existing code
- Jangan commit kecuali diminta eksplisit
- Documentation first — sinkronkan `.cursor/` setelah sesi besar
**Baca saat:** Selalu — konteks lingkungan & preferensi user.
---
## 2. Rules Cursor
**Lokasi:** `.cursor/rules/`
**Berisi:**
| File | Isi | Scope |
|------|-----|-------|
| `00-project-context.mdc` | Struktur repo, host SSH, deploy | Selalu |
| `01-enterprise-principles.mdc` | Prinsip enterprise & UX wajib | Selalu |
| `02-implementation-status.mdc` | Status fitur geonet-console (audit) | Selalu |
| `03-ai-behavior.mdc` | Perilaku agent, larangan, bahasa | Selalu |
| `04-geonet-uiux.mdc` | Standar UI/UX | `geonet-console/`, `databaselist/` |
| `05-laravel-backend.mdc` | Service layer, API, audit | `*.php` di apps |
| `06-infrastructure.mdc` | SSH, Mikrotik, skrip deploy | `scripts/`, `server/` |
**Baca saat:** Selalu — rules aktif untuk AI Agent.
---
## 3. Perintah (Sync Protocol)
**Lokasi:** `perintah.md` (root repo)
**Berisi:**
* Indeks cepat operasional (profil, konteks repo, status fitur, ringkasan agent)
* Aplikasi & stack produksi
* URL produksi (domain `gisportal.id`)
* Deploy dari laptop (PowerShell scripts)
* File konteks (update saat sync)
* Rules (daftar lengkap)
* Pertanyaan sync (checklist AI wajib akhir sesi)
* Sync terakhir (changelog singkat)
**Baca saat:** Akhir sesi / sebelum nyatakan done (wajib).
---
## 4. AGENTS.md
**Lokasi:** `AGENTS.md`
**Berisi:**
* Rules aktif (daftar lengkap dengan scope)
* Aplikasi utama (geonet-console, databaselist, sqlservercheck)
* Dokumentasi sumber (referensi lengkap)
* Ollama + NAS Project Search (ringkasan agent)
* Ringkasan keputusan
* Arsitektur
* API (target geonet-console)
* Timeline rekomendasi
* Checklist go-live
* Verifikasi agent aktif
**Baca saat:** Saat kerja Ollama/NAS search atau verifikasi rules.
---
## 5. Server Documentation
**Lokasi:** `server/`
**Berisi:**
* `host.md` — Daftar host & IP
* Audit server (Mikrotik, database, webserver, AD-LDAP, Zabbix, QNAP, Ollama)
* Konfigurasi Mikrotik (dual-wan, failover, hybrid, split-DNS)
* `migrate-geonet-console.md` — Rencana migrasi databaselist → geonet-console
* `ollama-nas-project-search-requirements.md` — Requirement lengkap Ollama + NAS search
* Konfigurasi nginx, SQL scripts
**Baca saat:** Saat kerja infrastruktur, Mikrotik, atau migrasi.
---
## 6. README & Quick Reference
**Lokasi:** `readme.txt`
**Berisi:**
* SSH commands (Mikrotik, reverse proxy, main database, AD-LDAP)
* Remote Docker Development (model kerja)
* SSH key setup
* Deploy dari laptop (PowerShell scripts)
* URL setelah deploy
* Edit .env di server
**Baca saat:** Saat deploy atau SSH ke server.
---
# Informasi Aplikasi
## Aplikasi di Repo
| Folder | URL Produksi | Stack Server | Status |
|--------|--------------|--------------|--------|
| `geonet-console/` | https://console.gisportal.id | `/opt/stacks/geonet-console/` | ✅ Aktif |
| `databaselist/` | https://databaselist.gisportal.id | `/opt/stacks/databaselist/` | ⚠ Redirect ke console |
| `sqlservercheck/` | https://sqlservercheck.gisportal.id | `/opt/stacks/sqlservercheck/` | ✅ Aktif |
## URL Produksi
### geonet-console
| Fungsi | URL |
|--------|-----|
| Login / dashboard | https://console.gisportal.id |
| API Clients (OAuth2 + API Tokens + Service Tokens) | https://console.gisportal.id/api-clients |
### databaselist (redirect ke console)
| Fungsi | URL |
|--------|-----|
| Daftar database | https://databaselist.gisportal.id/ |
| LDAP Users | https://databaselist.gisportal.id/ldap-users |
| LDAP Groups | https://databaselist.gisportal.id/ldap-groups |
| DB Connections | https://databaselist.gisportal.id/database-connections |
| Mail SMTP & template | https://databaselist.gisportal.id/mail-settings |
| Audit Logs | https://databaselist.gisportal.id/audit-logs |
| API Docs | https://databaselist.gisportal.id/api-docs |
| Profile (user) | https://databaselist.gisportal.id/profile |
| REST API | https://databaselist.gisportal.id/api/v1 |
### sqlservercheck
| Fungsi | URL |
|--------|-----|
| Tool cek SQL Server | https://sqlservercheck.gisportal.id/ts |
| | https://sqlservercheck.gisportal.id/nts |
---
# Service Tokens
Service Tokens adalah fitur di `geonet-console` untuk mengelola Bearer token microservice secara terpusat. Berbeda dari OAuth2 (flow kompleks) dan API Token (untuk user/script), Service Tokens khusus untuk autentikasi service-to-service sederhana.
## Arsitektur
- **Tabel:** `service_tokens` (di database `geonet_console`, bukan `geonetagent_dev`)
- **Model:** `app/Models/ServiceToken.php`
- **Service:** `app/Services/ServiceTokenService.php`
- **Controller (web):** `app/Http/Controllers/ApiClientController.php`
- **Controller (API):** `app/Http/Controllers/Api/V1/ServiceTokenController.php`
- **Migration:** `database/migrations/2026_06_23_130000_create_service_tokens_table.php`
- **UI:** `/api-clients` — tab ketiga di bawah API Tokens dan OAuth2 Clients
## Schema Tabel
| Kolom | Tipe | Deskripsi |
|-------|------|-----------|
| `id` | UUID PK | Identifier unik |
| `name` | varchar(100) | Nama token (e.g., "GeoNetAgent-Production") |
| `service` | varchar(50) | Nama service (e.g., "GeoNetAgent", "Databaselist") |
| `token_prefix` | varchar(16) unique | 16 karakter awal untuk identifikasi di UI |
| `token_hash` | varchar(64) unique | SHA256 hash token lengkap |
| `is_active` | boolean | Status aktif/revoked |
| `expires_at` | timestamptz nullable | Waktu kedaluwarsa (null = tidak expire) |
| `created_by` | varchar(100) | Username yang membuat token |
| `created_at` | timestamptz | Waktu pembuatan |
| `last_used_at` | timestamptz nullable | Waktu terakhir digunakan |
## API Endpoints (REST)
| Method | Endpoint | Fungsi |
|--------|----------|--------|
| GET | `/api/v1/service-tokens` | List semua token |
| POST | `/api/v1/service-tokens` | Buat token baru |
| GET | `/api/v1/service-tokens/{id}` | Detail token |
| POST | `/api/v1/service-tokens/{id}/revoke` | Revoke (nonaktifkan) |
| POST | `/api/v1/service-tokens/{id}/regenerate` | Regenerate (buat baru, revoke lama) |
| DELETE | `/api/v1/service-tokens/{id}` | Hapus permanen |
## Web Routes
| Method | Route | Fungsi |
|--------|-------|--------|
| POST | `/api-clients/service-tokens` | Buat token (redirect ke `/api-clients`) |
| POST | `/api-clients/service-tokens/{id}/revoke` | Revoke token |
| POST | `/api-clients/service-tokens/{id}/regenerate` | Regenerate token |
| DELETE | `/api-clients/service-tokens/{id}` | Hapus token |
## Zero Impact pada GeoNetAgent
- Database `service_tokens` ada di `geonet_console`, **bukan** di `geonetagent_dev`
- GeoNetAgent collector (`auth.py`) tetap membaca tabel `agent_tokens` di `geonetagent_dev`
- Tidak ada perubahan pada kode collector atau agent PowerShell
- Deploy fitur ini **tidak mengganggu** agent yang sudah terinstall
---
# Informasi Infrastruktur
## Host SSH
| Host | IP | Port | User | Fungsi |
|------|-----|------|------|--------|
| Mikrotik | 10.100.1.1 / 192.168.0.1 | 255 | admin | Gateway, routing, firewall |
| Reverse proxy | 10.100.1.24 | 22 | root | Nginx, Docker, stack `/opt/stacks/*` |
| Main database | 10.100.1.25 | 22 | root | PostgreSQL, SQL Server |
| AD-LDAP | 10.100.1.40 | 22 | Administrator | Windows Server 2012 R2 AD |
| Main webserver | 10.100.1.41 | 22 | Administrator | Web server |
| Zabbix | 10.100.1.42 | 22 | root | Monitoring Server |
| Ollama (aiopr) | 10.100.1.26 | 22 | geonet/root | AI inference (POC) |
| QNAP | 10.100.1.10 | 22 | admin | NAS SMB |
## Model Kerja
Edit kode di laptop → deploy via SSH → build & run di server. **Kredensial `.env` hanya di server**, jangan sync dari laptop.
## Deploy Commands
```powershell
.\scripts\deploy-geonet-console.ps1
.\scripts\deploy-databaselist.ps1
.\scripts\deploy-sqlservercheck.ps1
.\scripts\deploy-databaselist.ps1 -NoBuild # restart tanpa rebuild
```
---
# Status Fitur (geonet-console)
## Sudah Ada
- Laravel 11, PostgreSQL (app + audit), LDAP auth & manajemen user/grup
- Multi database dinamis (SQL Server, PostgreSQL, MySQL/MariaDB)
- Docker + deploy script + Nginx reverse proxy
- Audit log web/API + beacon page-view/duration
- API v1 + Passport OAuth2 + API token + Swagger UI
- **Service Tokens** — Bearer token management untuk microservice (GeoNetAgent, Databaselist, dll.) di `/api-clients`
- Service layer kuat (15+ services, termasuk `ServiceTokenService`)
- UI: Turbo SPA, dark/light/system, PWA install, tabel mobile action sheet
## Partial
- API Management (belum gateway/rate-plan penuh)
- Security (belum MFA/SSO/RBAC granular)
- Audit immutable (belum DB trigger/hash chain)
- PWA (belum push notification & background sync)
- Production hardening (Tailwind CDN di prod, default password di compose example)
- Clean architecture (Application layer ada, Domain belum)
## Belum
- CMS, MFA, SSO user (SAML/OIDC), Maker Checker, Four Eyes Principle
- Repository pattern, DTO formal, Event driven, Queue processing
- Approval queue UI
---
# Ollama + NAS Project Search
**Status:** Direncanakan (belum diimplementasi di geonet-console).
**Dokumen lengkap:** `server/ollama-nas-project-search-requirements.md`
### Ringkasan Keputusan
| Pertanyaan | Jawaban |
|------------|---------|
| Ollama bisa langsung scan `\\10.100.1.10\project`? | **Tidak** — perlu **indexer + PostgreSQL** |
| Multi-folder (project, Marketing, dll.)? | **Ya** — via env **indexer**, bukan env Ollama |
| Host Ollama POC & CPU | **VM `10.100.1.26` (aiopr)** — dedicated Ubuntu 22.04 |
| Host Ollama GPU (target) | Migrasi **Windows 11 + GPU** (~6 bulan) |
| Model Ubuntu → Windows portable? | **Ya** — copy `~/.ollama/models/` atau `ollama pull` ulang |
| Indeks RAG pindah ke PC Windows? | **Tidak perlu** — tetap di PostgreSQL `10.100.1.25` |
| License code / tanggal expired dari PDF? | **Ya**`license_registry` + parser terstruktur |
### API Target (geonet-console)
| Endpoint | Method | Fungsi |
|----------|--------|--------|
| `/api/v1/project-search/ask` | POST | Query natural language + optional `sources[]` |
| `/api/v1/project-search/index/status` | GET | Status indeks per source |
| `/api/v1/project-search/sources` | GET | Daftar source dari env |
| `/api/v1/license-search/ask` | POST | Query license natural language |
| `/api/v1/license-search/code/{code}` | GET | Lookup exact license code |
| `/api/v1/license-search/expiring` | GET | License mendekati expired |
| `/api/v1/license-search/reindex` | POST | Re-parse file (admin, audit) |
---
# Checklist Sebelum Implementasi
AI wajib memastikan:
☐ Sudah membaca `context-index.md`
☐ Sudah membaca `me.md` — profil & infrastruktur
☐ Sudah membaca `perintah.md` — protokol sync
☐ Sudah membaca rules yang relevan di `.cursor/rules/`
☐ Sudah memahami status implementasi (`02-implementation-status.mdc`)
☐ Sudah memahami arsitektur yang ada
---
# Checklist Setelah Implementasi
AI wajib memperbarui:
`perintah.md` — sync terakhir (wajib akhir sesi)
`me.md` — jika infrastruktur berubah
☐ Rules yang relevan — jika workflow berubah
`AGENTS.md` — jika ada perubahan roadmap Ollama/NAS
`context-index.md` — jika ada dokumen baru
---
# Prinsip Pengembangan
Setiap perubahan harus memenuhi prinsip berikut:
* Mudah dipahami developer baru.
* Mudah dipahami AI Agent lain (tanpa riwayat chat).
* Mudah diaudit.
* Mudah di-maintain.
* Memiliki rollback plan.
* Memiliki dokumentasi lengkap.
* Tidak menghasilkan technical debt yang tidak terdokumentasi.
* Repository adalah sumber kebenaran utama.
---
# Penutup
Repository ini dirancang agar:
* Developer baru dapat memahami proyek dengan cepat.
* AI Agent mana pun dapat melanjutkan pekerjaan tanpa kehilangan konteks.
* Seluruh pengetahuan proyek tersimpan di dalam repository.
* Dokumentasi selalu selaras dengan implementasi.
Repository adalah sumber kebenaran utama.
Jika terjadi perbedaan antara implementasi dan dokumentasi, AI wajib memperbarui dokumentasi hingga keduanya konsisten.
---
# Bahasa & Gaya
* Komunikasi dengan user: **Bahasa Indonesia**
* Kode & komentar: **English** (konvensi industri)
* Dokumentasi `.cursor/`: **Bahasa Indonesia** (kecuali istilah teknis)

289
server-connection/geonet-console/app/Http/Controllers/Api/V1/ServiceTokenController.php

@ -0,0 +1,289 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\ServiceToken;
use App\Services\ServiceTokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* ServiceTokenController - API endpoints for service token management
*
* Provides REST API for CRUD operations on service tokens.
* All endpoints require authentication via LDAP or API token.
*/
class ServiceTokenController extends Controller
{
public function __construct(
private ServiceTokenService $serviceTokenService
) {}
/**
* List all service tokens
*
* GET /api/v1/service-tokens
*/
public function index(): JsonResponse
{
$tokens = $this->serviceTokenService->listAll();
return response()->json([
'data' => array_map(fn ($token) => [
'id' => $token->id,
'name' => $token->name,
'service' => $token->service,
'token_prefix' => $token->token_prefix,
'is_active' => $token->is_active,
'expires_at' => $token->expires_at?->toIso8601String(),
'created_by' => $token->created_by,
'created_at' => $token->created_at->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
'is_expired' => $token->isExpired(),
], $tokens),
]);
}
/**
* Create a new service token
*
* POST /api/v1/service-tokens
*
* @bodyParam name string required Token name (e.g., "GeoNetAgent-Production")
* @bodyParam service string required Service name (e.g., "GeoNetAgent", "Databaselist")
* @bodyParam expires_in_days int optional Expiration in days (null = never expires)
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'service' => 'required|string|max:50',
'expires_in_days' => 'nullable|integer|min:1',
]);
$result = $this->serviceTokenService->create(
name: $validated['name'],
service: $validated['service'],
expiresInDays: $validated['expires_in_days'] ?? null
);
return response()->json([
'data' => [
'id' => $result['model']->id,
'name' => $result['model']->name,
'service' => $result['model']->service,
'token_prefix' => $result['model']->token_prefix,
'token' => $result['token'], // Only shown once on creation
'is_active' => $result['model']->is_active,
'expires_at' => $result['model']->expires_at?->toIso8601String(),
'created_by' => $result['model']->created_by,
'created_at' => $result['model']->created_at->toIso8601String(),
],
'message' => 'Service token created successfully. Save the token securely as it will not be shown again.',
], 201);
}
/**
* Get a specific service token
*
* GET /api/v1/service-tokens/{id}
*/
public function show(string $id): JsonResponse
{
$token = $this->serviceTokenService->getById($id);
if (!$token) {
return response()->json([
'error' => [
'code' => 'NOT_FOUND',
'message' => 'Service token not found',
],
], 404);
}
return response()->json([
'data' => [
'id' => $token->id,
'name' => $token->name,
'service' => $token->service,
'token_prefix' => $token->token_prefix,
'is_active' => $token->is_active,
'expires_at' => $token->expires_at?->toIso8601String(),
'created_by' => $token->created_by,
'created_at' => $token->created_at->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
'is_expired' => $token->isExpired(),
],
]);
}
/**
* Revoke a service token (deactivate)
*
* POST /api/v1/service-tokens/{id}/revoke
*/
public function revoke(string $id): JsonResponse
{
$token = $this->serviceTokenService->getById($id);
if (!$token) {
return response()->json([
'error' => [
'code' => 'NOT_FOUND',
'message' => 'Service token not found',
],
], 404);
}
$this->serviceTokenService->revoke($token);
return response()->json([
'message' => 'Service token revoked successfully',
]);
}
/**
* Regenerate a service token (create new, revoke old)
*
* POST /api/v1/service-tokens/{id}/regenerate
*
* @bodyParam expires_in_days int optional New expiration in days
*/
public function regenerate(Request $request, string $id): JsonResponse
{
$validated = $request->validate([
'expires_in_days' => 'nullable|integer|min:1',
]);
$token = $this->serviceTokenService->getById($id);
if (!$token) {
return response()->json([
'error' => [
'code' => 'NOT_FOUND',
'message' => 'Service token not found',
],
], 404);
}
$result = $this->serviceTokenService->regenerate(
oldToken: $token,
expiresInDays: $validated['expires_in_days'] ?? null
);
return response()->json([
'data' => [
'id' => $result['model']->id,
'name' => $result['model']->name,
'service' => $result['model']->service,
'token_prefix' => $result['model']->token_prefix,
'token' => $result['token'], // Only shown once on regeneration
'is_active' => $result['model']->is_active,
'expires_at' => $result['model']->expires_at?->toIso8601String(),
'created_by' => $result['model']->created_by,
'created_at' => $result['model']->created_at->toIso8601String(),
],
'message' => 'Service token regenerated successfully. Save the new token securely as it will not be shown again.',
]);
}
/**
* Delete a service token permanently
*
* DELETE /api/v1/service-tokens/{id}
*/
public function destroy(string $id): JsonResponse
{
$token = $this->serviceTokenService->getById($id);
if (!$token) {
return response()->json([
'error' => [
'code' => 'NOT_FOUND',
'message' => 'Service token not found',
],
], 404);
}
$this->serviceTokenService->delete($token);
return response()->json([
'message' => 'Service token deleted successfully',
]);
}
/**
* Get tokens by service
*
* GET /api/v1/service-tokens/by-service/{service}
*/
public function getByService(string $service): JsonResponse
{
$tokens = $this->serviceTokenService->getByService($service);
return response()->json([
'data' => array_map(fn ($token) => [
'id' => $token->id,
'name' => $token->name,
'service' => $token->service,
'token_prefix' => $token->token_prefix,
'is_active' => $token->is_active,
'expires_at' => $token->expires_at?->toIso8601String(),
'created_by' => $token->created_by,
'created_at' => $token->created_at->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
'is_expired' => $token->isExpired(),
], $tokens),
]);
}
/**
* Get active tokens only
*
* GET /api/v1/service-tokens/active
*/
public function getActive(): JsonResponse
{
$tokens = $this->serviceTokenService->getActive();
return response()->json([
'data' => array_map(fn ($token) => [
'id' => $token->id,
'name' => $token->name,
'service' => $token->service,
'token_prefix' => $token->token_prefix,
'is_active' => $token->is_active,
'expires_at' => $token->expires_at?->toIso8601String(),
'created_by' => $token->created_by,
'created_at' => $token->created_at->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
], $tokens),
]);
}
/**
* Get expired tokens
*
* GET /api/v1/service-tokens/expired
*/
public function getExpired(): JsonResponse
{
$tokens = $this->serviceTokenService->getExpired();
return response()->json([
'data' => array_map(fn ($token) => [
'id' => $token->id,
'name' => $token->name,
'service' => $token->service,
'token_prefix' => $token->token_prefix,
'is_active' => $token->is_active,
'expires_at' => $token->expires_at?->toIso8601String(),
'created_by' => $token->created_by,
'created_at' => $token->created_at->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
], $tokens),
]);
}
}

84
server-connection/geonet-console/app/Http/Controllers/ApiClientController.php

@ -3,7 +3,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\ApiToken; use App\Models\ApiToken;
use App\Models\ServiceToken;
use App\Services\ApiTokenService; use App\Services\ApiTokenService;
use App\Services\ServiceTokenService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@ -14,6 +16,7 @@ class ApiClientController extends Controller
{ {
public function __construct( public function __construct(
private readonly ApiTokenService $apiTokens, private readonly ApiTokenService $apiTokens,
private readonly ServiceTokenService $serviceTokens,
private readonly ClientRepository $clientRepository private readonly ClientRepository $clientRepository
) { ) {
} }
@ -23,9 +26,11 @@ class ApiClientController extends Controller
return view('api-clients.index', [ return view('api-clients.index', [
'apiTokens' => $this->apiTokens->listAll(), 'apiTokens' => $this->apiTokens->listAll(),
'oauthClients' => Client::orderByDesc('created_at')->get(), 'oauthClients' => Client::orderByDesc('created_at')->get(),
'serviceTokens' => $this->serviceTokens->listAll(),
'scopes' => config('api.scopes'), 'scopes' => config('api.scopes'),
'newToken' => session('new_api_token'), 'newToken' => session('new_api_token'),
'newClient' => session('new_oauth_client'), 'newClient' => session('new_oauth_client'),
'newServiceToken' => session('new_service_token'),
]); ]);
} }
@ -94,4 +99,83 @@ class ApiClientController extends Controller
return redirect()->route('api-clients.index')->with('success', 'OAuth2 client revoked.'); return redirect()->route('api-clients.index')->with('success', 'OAuth2 client revoked.');
} }
// Service Tokens (microservice Bearer token management)
public function storeServiceToken(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:100'],
'service' => ['required', 'string', 'max:50'],
'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:3650'],
]);
$result = $this->serviceTokens->create(
name: $data['name'],
service: $data['service'],
expiresInDays: $data['expires_in_days'] ?? null
);
return redirect()
->route('api-clients.index')
->with('success', 'Service token created. Copy it now — it will not be shown again.')
->with('new_service_token', [
'name' => $result['model']->name,
'service' => $result['model']->service,
'token' => $result['token'],
]);
}
public function revokeServiceToken(Request $request, string $id): RedirectResponse
{
$token = $this->serviceTokens->getById($id);
if (!$token) {
return redirect()->route('api-clients.index')->with('error', 'Service token not found.');
}
$this->serviceTokens->revoke($token);
return redirect()->route('api-clients.index')->with('success', 'Service token revoked.');
}
public function regenerateServiceToken(Request $request, string $id): RedirectResponse
{
$data = $request->validate([
'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:3650'],
]);
$token = $this->serviceTokens->getById($id);
if (!$token) {
return redirect()->route('api-clients.index')->with('error', 'Service token not found.');
}
$result = $this->serviceTokens->regenerate(
oldToken: $token,
expiresInDays: $data['expires_in_days'] ?? null
);
return redirect()
->route('api-clients.index')
->with('success', 'Service token regenerated. Copy the new token now.')
->with('new_service_token', [
'name' => $result['model']->name,
'service' => $result['model']->service,
'token' => $result['token'],
]);
}
public function destroyServiceToken(Request $request, string $id): RedirectResponse
{
$token = $this->serviceTokens->getById($id);
if (!$token) {
return redirect()->route('api-clients.index')->with('error', 'Service token not found.');
}
$this->serviceTokens->delete($token);
return redirect()->route('api-clients.index')->with('success', 'Service token deleted.');
}
} }

134
server-connection/geonet-console/app/Models/ServiceToken.php

@ -0,0 +1,134 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* ServiceToken - Bearer token management for microservices
*
* This model manages Bearer tokens for microservices like GeoNetAgent,
* Databaselist, SQLServerCheck, etc. Tokens are stored as SHA256 hashes
* with a prefix for UI display.
*
* Zero impact on existing GeoNetAgent agents (they use separate database).
*/
class ServiceToken extends Model
{
protected $fillable = [
'name',
'service',
'token_prefix',
'token_hash',
'is_active',
'expires_at',
'created_by',
'last_used_at',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'expires_at' => 'datetime',
'created_at' => 'datetime',
'last_used_at' => 'datetime',
];
}
/**
* Generate a new service token
*
* @param string $name Token name (e.g., "GeoNetAgent-Production")
* @param string $service Service name (e.g., "GeoNetAgent")
* @param string|null $createdBy User who created the token
* @param int|null $expiresInDays Expiration in days (null = never expires)
* @return array ['token' => string, 'model' => ServiceToken]
*/
public static function generate(
string $name,
string $service,
?string $createdBy = null,
?int $expiresInDays = null
): array {
$token = bin2hex(random_bytes(32)); // 64 chars hex
$tokenPrefix = substr($token, 0, 16);
$tokenHash = hash('sha256', $token);
$expiresAt = $expiresInDays
? now()->addDays($expiresInDays)
: null;
$model = self::create([
'id' => (string) \Illuminate\Support\Str::uuid(),
'name' => $name,
'service' => $service,
'token_prefix' => $tokenPrefix,
'token_hash' => $tokenHash,
'is_active' => true,
'expires_at' => $expiresAt,
'created_by' => $createdBy,
]);
return [
'token' => $token,
'model' => $model,
];
}
/**
* Verify a token against the hash
*
* @param string $token Plain token
* @return ServiceToken|null
*/
public static function verify(string $token): ?self
{
$tokenHash = hash('sha256', $token);
return self::where('token_hash', $tokenHash)
->where('is_active', true)
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->first();
}
/**
* Update last used timestamp
*/
public function touchLastUsed(): void
{
$this->update(['last_used_at' => now()]);
}
/**
* Scope for active tokens
*/
public function scopeActive($query)
{
return $query->where('is_active', true)
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/**
* Scope for expired tokens
*/
public function scopeExpired($query)
{
return $query->where('expires_at', '<', now());
}
/**
* Check if token is expired
*/
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}

228
server-connection/geonet-console/app/Services/ServiceTokenService.php

@ -0,0 +1,228 @@
<?php
namespace App\Services;
use App\Models\ServiceToken;
use App\Services\AuditLogService;
use Illuminate\Support\Facades\Auth;
/**
* ServiceTokenService - Manage microservice Bearer tokens
*
* Provides CRUD operations for service tokens with audit logging.
* Zero impact on existing GeoNetAgent agents (separate database).
*/
class ServiceTokenService
{
public function __construct(
private AuditLogService $auditLogService
) {}
/**
* Create a new service token
*
* @param string $name Token name (e.g., "GeoNetAgent-Production")
* @param string $service Service name (e.g., "GeoNetAgent", "Databaselist")
* @param int|null $expiresInDays Expiration in days (null = never expires)
* @return array{token: string, model: ServiceToken}
*/
public function create(string $name, string $service, ?int $expiresInDays = null): array
{
$createdBy = Auth::check() ? Auth::user()->username : 'system';
$result = ServiceToken::generate(
name: $name,
service: $service,
createdBy: $createdBy,
expiresInDays: $expiresInDays
);
// Audit log
$this->auditLogService->log(
action: 'service_token_created',
entityType: 'ServiceToken',
entityId: $result['model']->id,
details: [
'name' => $name,
'service' => $service,
'token_prefix' => $result['model']->token_prefix,
'expires_at' => $result['model']->expires_at?->toIso8601String(),
],
userId: $createdBy
);
return $result;
}
/**
* List all service tokens (without plain tokens)
*
* @return array<ServiceToken>
*/
public function listAll(): array
{
return ServiceToken::orderByDesc('created_at')->get()->all();
}
/**
* Get a specific service token by ID
*/
public function getById(string $id): ?ServiceToken
{
return ServiceToken::find($id);
}
/**
* Revoke (deactivate) a service token
*/
public function revoke(ServiceToken $token): void
{
$token->update(['is_active' => false]);
$createdBy = Auth::check() ? Auth::user()->username : 'system';
$this->auditLogService->log(
action: 'service_token_revoked',
entityType: 'ServiceToken',
entityId: $token->id,
details: [
'name' => $token->name,
'service' => $token->service,
'token_prefix' => $token->token_prefix,
],
userId: $createdBy
);
}
/**
* Permanently delete a service token
*/
public function delete(ServiceToken $token): void
{
$tokenId = $token->id;
$tokenName = $token->name;
$tokenService = $token->service;
$tokenPrefix = $token->token_prefix;
$token->delete();
$createdBy = Auth::check() ? Auth::user()->username : 'system';
$this->auditLogService->log(
action: 'service_token_deleted',
entityType: 'ServiceToken',
entityId: $tokenId,
details: [
'name' => $tokenName,
'service' => $tokenService,
'token_prefix' => $tokenPrefix,
],
userId: $createdBy
);
}
/**
* Regenerate a service token (create new, revoke old)
*
* @return array{token: string, model: ServiceToken}
*/
public function regenerate(ServiceToken $oldToken, ?int $expiresInDays = null): array
{
// Revoke old token
$this->revoke($oldToken);
// Create new token with same name and service
return $this->create(
name: $oldToken->name,
service: $oldToken->service,
expiresInDays: $expiresInDays
);
}
/**
* Verify a token (for external validation, not used in UI)
*
* @param string $plainToken Plain token to verify
* @return ServiceToken|null
*/
public function verify(string $plainToken): ?ServiceToken
{
$token = ServiceToken::verify($plainToken);
if ($token) {
$token->touchLastUsed();
}
return $token;
}
/**
* Get tokens by service
*
* @param string $service Service name
* @return array<ServiceToken>
*/
public function getByService(string $service): array
{
return ServiceToken::where('service', $service)
->orderByDesc('created_at')
->get()
->all();
}
/**
* Get active tokens only
*
* @return array<ServiceToken>
*/
public function getActive(): array
{
return ServiceToken::active()
->orderByDesc('created_at')
->get()
->all();
}
/**
* Get expired tokens
*
* @return array<ServiceToken>
*/
public function getExpired(): array
{
return ServiceToken::expired()
->orderByDesc('expires_at')
->get()
->all();
}
/**
* Clean up expired tokens (optional maintenance task)
*
* @param int $olderThanDays Delete tokens expired more than X days ago
* @return int Number of deleted tokens
*/
public function cleanupExpired(int $olderThanDays = 30): int
{
$cutoffDate = now()->subDays($olderThanDays);
$deleted = ServiceToken::expired()
->where('expires_at', '<', $cutoffDate)
->delete();
if ($deleted > 0) {
$this->auditLogService->log(
action: 'service_tokens_cleanup',
entityType: 'ServiceToken',
entityId: null,
details: [
'deleted_count' => $deleted,
'older_than_days' => $olderThanDays,
],
userId: 'system'
);
}
return $deleted;
}
}

39
server-connection/geonet-console/database/migrations/2026_06_23_130000_create_service_tokens_table.php

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Create service_tokens table for managing microservice Bearer tokens.
* This is separate from OAuth2 clients and api_tokens for clear separation.
* Zero impact on existing GeoNetAgent agents (they use separate database).
*/
public function up(): void
{
Schema::create('service_tokens', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name', 100); // e.g., "GeoNetAgent-Production", "Databaselist-API"
$table->string('service', 50); // e.g., "GeoNetAgent", "Databaselist", "SQLServerCheck"
$table->string('token_prefix', 16)->unique(); // First 16 chars for UI display
$table->string('token_hash', 64)->unique(); // SHA256 hash of full token
$table->boolean('is_active')->default(true);
$table->timestamp('expires_at')->nullable();
$table->string('created_by', 100)->nullable(); // User who created the token
$table->timestamp('created_at')->useCurrent();
$table->timestamp('last_used_at')->nullable();
// Indexes
$table->index('service');
$table->index('is_active');
$table->index('expires_at');
});
}
public function down(): void
{
Schema::dropIfExists('service_tokens');
}
};

98
server-connection/geonet-console/resources/views/api-clients/index.blade.php

@ -31,7 +31,15 @@
</div> </div>
@endif @endif
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> @if ($newServiceToken)
<div class="mb-6 rounded-lg bg-amber-950/50 border border-amber-800 p-4 space-y-2 text-sm">
<p class="text-amber-200 font-medium">New Service Token: {{ $newServiceToken['name'] }} ({{ $newServiceToken['service'] }})</p>
<p class="text-slate-300">Token: <code class="text-cyan-300 break-all select-all">{{ $newServiceToken['token'] }}</code></p>
<p class="text-amber-400/70 text-xs">Salin sekarang. Token tidak ditampilkan lagi.</p>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6"> <div class="bg-slate-900 border border-slate-800 rounded-2xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Create API Token</h2> <h2 class="text-lg font-semibold text-white mb-4">Create API Token</h2>
<form method="POST" action="{{ route('api-clients.tokens.store') }}" class="space-y-4"> <form method="POST" action="{{ route('api-clients.tokens.store') }}" class="space-y-4">
@ -79,6 +87,31 @@
</button> </button>
</form> </form>
</div> </div>
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Create Service Token</h2>
<form method="POST" action="{{ route('api-clients.service-tokens.store') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm text-slate-300 mb-1">Name</label>
<input name="name" required maxlength="100" placeholder="GeoNetAgent-Production"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Service</label>
<input name="service" required maxlength="50" placeholder="GeoNetAgent"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Expires (days, optional)</label>
<input type="number" name="expires_in_days" min="1" max="3650" placeholder="kosong = tidak expire"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<button type="submit" class="rounded-lg bg-amber-600 hover:bg-amber-500 px-5 py-2 text-sm font-medium text-white">
Create Service Token
</button>
</form>
</div>
</div> </div>
<div class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden flex flex-col list-panel"> <div class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden flex flex-col list-panel">
@ -122,6 +155,65 @@
</div> </div>
</div> </div>
<div class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden flex flex-col list-panel">
<div class="list-panel__toolbar p-4 border-b border-slate-800">
<h2 class="font-semibold text-white">Service Tokens ({{ $serviceTokens->where('is_active', true)->count() }})</h2>
</div>
<div class="list-panel__scroll">
<table class="min-w-full text-sm list-table list-table--row-actions">
<thead class="text-slate-300">
<tr>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Name</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Service</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Prefix</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Status</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Last Used</th>
<th class="list-col-actions px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Action</th>
@include('partials.list-table-row-hint', ['tag' => 'th'])
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
@forelse ($serviceTokens as $token)
<tr class="{{ !$token->is_active ? 'opacity-50' : '' }}" data-list-row-title="{{ $token->name }}">
<td class="px-4 py-3 text-white">{{ $token->name }}</td>
<td class="px-4 py-3 text-slate-300">{{ $token->service }}</td>
<td class="px-4 py-3 font-mono text-slate-400">{{ $token->token_prefix }}...</td>
<td class="px-4 py-3">
<span class="text-xs {{ !$token->is_active ? 'text-red-400' : ($token->isExpired() ? 'text-amber-400' : 'text-emerald-400') }}">
{{ !$token->is_active ? 'Revoked' : ($token->isExpired() ? 'Expired' : 'Active') }}
</span>
</td>
<td class="px-4 py-3 text-slate-400">{{ $token->last_used_at?->format('Y-m-d H:i') ?? '—' }}</td>
<td class="list-col-actions px-4 py-3 flex gap-1">
@if ($token->is_active)
<form method="POST" action="{{ route('api-clients.service-tokens.regenerate', $token->id) }}"
onsubmit="return confirm('Regenerate token [{{ $token->name }}]? Old token will be revoked.');">
@csrf
<button class="rounded bg-amber-700 hover:bg-amber-600 text-white text-xs px-2 py-1">Regenerate</button>
</form>
<form method="POST" action="{{ route('api-clients.service-tokens.revoke', $token->id) }}"
onsubmit="return confirm('Revoke token [{{ $token->name }}]?');">
@csrf
<button class="rounded bg-red-800 hover:bg-red-700 text-white text-xs px-2 py-1">Revoke</button>
</form>
@endif
<form method="POST" action="{{ route('api-clients.service-tokens.destroy', $token->id) }}"
onsubmit="return confirm('Delete token [{{ $token->name }}]?');">
@csrf
@method('DELETE')
<button class="rounded bg-slate-700 hover:bg-slate-600 text-white text-xs px-2 py-1">Delete</button>
</form>
</td>
@include('partials.list-table-row-hint', ['tag' => 'td'])
</tr>
@empty
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-400">No service tokens.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden flex flex-col list-panel"> <div class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden flex flex-col list-panel">
<div class="list-panel__toolbar p-4 border-b border-slate-800"> <div class="list-panel__toolbar p-4 border-b border-slate-800">
<h2 class="font-semibold text-white">OAuth2 Clients ({{ $oauthClients->where('revoked', false)->count() }})</h2> <h2 class="font-semibold text-white">OAuth2 Clients ({{ $oauthClients->where('revoked', false)->count() }})</h2>
@ -178,6 +270,10 @@
<dt class="font-medium text-violet-400 mb-1">OAuth2 Client</dt> <dt class="font-medium text-violet-400 mb-1">OAuth2 Client</dt>
<dd>Client Credentials untuk integrasi enterprise. Request token ke <code class="text-cyan-300">/oauth/token</code>.</dd> <dd>Client Credentials untuk integrasi enterprise. Request token ke <code class="text-cyan-300">/oauth/token</code>.</dd>
</div> </div>
<div>
<dt class="font-medium text-amber-400 mb-1">Service Token</dt>
<dd>Bearer token untuk microservice (GeoNetAgent, Databaselist). Tanpa OAuth flow, simple Bearer auth.</dd>
</div>
<div> <div>
<dt class="font-medium text-amber-400 mb-1">Keamanan</dt> <dt class="font-medium text-amber-400 mb-1">Keamanan</dt>
<dd>Secret/token hanya ditampilkan sekali saat dibuat. Revoke segera jika bocor.</dd> <dd>Secret/token hanya ditampilkan sekali saat dibuat. Revoke segera jika bocor.</dd>

21
server-connection/geonet-console/routes/api.php

@ -6,6 +6,7 @@ use App\Http\Controllers\Api\V1\LdapGroupController;
use App\Http\Controllers\Api\V1\LdapUserController; use App\Http\Controllers\Api\V1\LdapUserController;
use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MeController;
use App\Http\Controllers\Api\V1\ProjectSearchController; use App\Http\Controllers\Api\V1\ProjectSearchController;
use App\Http\Controllers\Api\V1\ServiceTokenController;
use App\Http\Controllers\Api\V1\TokenGuideController; use App\Http\Controllers\Api\V1\TokenGuideController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -67,5 +68,25 @@ Route::prefix('v1')->group(function () {
Route::post('/project-search/ask', [ProjectSearchController::class, 'ask']) Route::post('/project-search/ask', [ProjectSearchController::class, 'ask'])
->middleware(['api.scope:project-search:read', 'throttle:20,1']) ->middleware(['api.scope:project-search:read', 'throttle:20,1'])
->name('api.v1.project-search.ask'); ->name('api.v1.project-search.ask');
// Service Tokens (microservice Bearer token management)
Route::get('/service-tokens', [ServiceTokenController::class, 'index'])
->name('api.v1.service-tokens.index');
Route::post('/service-tokens', [ServiceTokenController::class, 'store'])
->name('api.v1.service-tokens.store');
Route::get('/service-tokens/{id}', [ServiceTokenController::class, 'show'])
->name('api.v1.service-tokens.show');
Route::post('/service-tokens/{id}/revoke', [ServiceTokenController::class, 'revoke'])
->name('api.v1.service-tokens.revoke');
Route::post('/service-tokens/{id}/regenerate', [ServiceTokenController::class, 'regenerate'])
->name('api.v1.service-tokens.regenerate');
Route::delete('/service-tokens/{id}', [ServiceTokenController::class, 'destroy'])
->name('api.v1.service-tokens.destroy');
Route::get('/service-tokens/by-service/{service}', [ServiceTokenController::class, 'getByService'])
->name('api.v1.service-tokens.by-service');
Route::get('/service-tokens/active', [ServiceTokenController::class, 'getActive'])
->name('api.v1.service-tokens.active');
Route::get('/service-tokens/expired', [ServiceTokenController::class, 'getExpired'])
->name('api.v1.service-tokens.expired');
}); });
}); });

6
server-connection/geonet-console/routes/web.php

@ -90,6 +90,12 @@ Route::middleware(['auth.ldap', 'auth.admin', 'audit.web'])->group(function () {
Route::post('/api-clients/oauth', [ApiClientController::class, 'storeOAuthClient'])->name('api-clients.oauth.store'); Route::post('/api-clients/oauth', [ApiClientController::class, 'storeOAuthClient'])->name('api-clients.oauth.store');
Route::delete('/api-clients/oauth/{clientId}', [ApiClientController::class, 'destroyOAuthClient'])->name('api-clients.oauth.destroy'); Route::delete('/api-clients/oauth/{clientId}', [ApiClientController::class, 'destroyOAuthClient'])->name('api-clients.oauth.destroy');
// Service Tokens (microservice Bearer token management)
Route::post('/api-clients/service-tokens', [ApiClientController::class, 'storeServiceToken'])->name('api-clients.service-tokens.store');
Route::post('/api-clients/service-tokens/{id}/revoke', [ApiClientController::class, 'revokeServiceToken'])->name('api-clients.service-tokens.revoke');
Route::post('/api-clients/service-tokens/{id}/regenerate', [ApiClientController::class, 'regenerateServiceToken'])->name('api-clients.service-tokens.regenerate');
Route::delete('/api-clients/service-tokens/{id}', [ApiClientController::class, 'destroyServiceToken'])->name('api-clients.service-tokens.destroy');
Route::get('/mail-settings', [MailSettingsController::class, 'index'])->name('mail-settings.index'); Route::get('/mail-settings', [MailSettingsController::class, 'index'])->name('mail-settings.index');
Route::put('/mail-settings/smtp', [MailSettingsController::class, 'updateSmtp'])->name('mail-settings.smtp.update'); Route::put('/mail-settings/smtp', [MailSettingsController::class, 'updateSmtp'])->name('mail-settings.smtp.update');
Route::post('/mail-settings/test', [MailSettingsController::class, 'testEmail'])->name('mail-settings.test'); Route::post('/mail-settings/test', [MailSettingsController::class, 'testEmail'])->name('mail-settings.test');

149
server-connection/perintah.md

@ -0,0 +1,149 @@
# Protokol Sync Dokumentasi AI
> **Wajib** di akhir sesi pengembangan. Repository = sumber kebenaran; percakapan bukan dokumentasi.
> Repo: **server-connection** · Konteks pemilik: `.cursor/me.md`
## Indeks cepat (operasional)
| Topik | File / URL |
|-------|------------|
| Profil & infrastruktur | `.cursor/me.md` |
| Konteks repo, host SSH, deploy | `.cursor/rules/00-project-context.mdc` |
| Perilaku AI agent | `.cursor/rules/03-ai-behavior.mdc` |
| Status fitur geonet-console | `.cursor/rules/02-implementation-status.mdc` |
| Ringkasan agent & Ollama/NAS | `AGENTS.md` |
| Requirement bisnis | `rules`, `rules-adv` |
| SSH & deploy cepat | `readme.txt` |
| Host & IP | `server/host.txt` |
| geonet-console | https://console.gisportal.id |
| databaselist | https://databaselist.gisportal.id |
| sqlservercheck | https://sqlservercheck.gisportal.id |
### Aplikasi & stack produksi
| Folder | URL | Stack server |
|--------|-----|--------------|
| `geonet-console/` | https://console.gisportal.id | `/opt/stacks/geonet-console/` |
| `databaselist/` | https://databaselist.gisportal.id | `/opt/stacks/databaselist/` |
| `sqlservercheck/` | https://sqlservercheck.gisportal.id | `/opt/stacks/sqlservercheck/` |
### URL produksi (domain `gisportal.id`)
#### geonet-console
| Fungsi | URL |
|--------|-----|
| Login / dashboard | https://console.gisportal.id |
#### databaselist
| Fungsi | URL |
|--------|-----|
| Daftar database | https://databaselist.gisportal.id/ |
| LDAP Users | https://databaselist.gisportal.id/ldap-users |
| LDAP Groups | https://databaselist.gisportal.id/ldap-groups |
| DB Connections | https://databaselist.gisportal.id/database-connections |
| Mail SMTP & template | https://databaselist.gisportal.id/mail-settings |
| Audit Logs | https://databaselist.gisportal.id/audit-logs |
| API Docs | https://databaselist.gisportal.id/api-docs |
| Profile (user) | https://databaselist.gisportal.id/profile |
| REST API | https://databaselist.gisportal.id/api/v1 |
| API Clients (OAuth2 + API Tokens + Service Tokens) | https://console.gisportal.id/api-clients |
#### sqlservercheck
| Fungsi | URL |
|--------|-----|
| Tool cek SQL Server | https://sqlservercheck.gisportal.id/ts |
| | https://sqlservercheck.gisportal.id/nts |
### Deploy dari laptop
```powershell
.\scripts\deploy-geonet-console.ps1
.\scripts\deploy-databaselist.ps1
.\scripts\deploy-sqlservercheck.ps1
.\scripts\deploy-databaselist.ps1 -NoBuild # restart tanpa rebuild
```
SSH reverse proxy: `ssh -m hmac-sha1 root@10.100.1.24 -p 22` · Edit `.env` hanya di server.
### File konteks (update saat sync)
| File | Isi |
|------|-----|
| `.cursor/me.md` | Profil pemilik & infrastruktur |
| `.cursor/rules/*.mdc` | Rules Cursor |
| `AGENTS.md` | Ringkasan agent, roadmap Ollama/NAS |
| `perintah.md` | Protokol sync (file ini) |
| `readme.txt` | SSH, deploy, URL |
| `rules` / `rules-adv` | Requirement bisnis manual |
| `server/ollama-nas-project-search-requirements.md` | Requirement Ollama + NAS |
| Rules | Isi |
|-------|-----|
| `00-project-context.mdc` | Repo, host, deploy |
| `01-enterprise-principles.mdc` | Prinsip enterprise & UX |
| `02-implementation-status.mdc` | Status geonet-console |
| `03-ai-behavior.mdc` | Perilaku agent |
| `04-geonet-uiux.mdc` | Standar UI/UX |
| `05-laravel-backend.mdc` | Backend Laravel |
| `06-infrastructure.mdc` | SSH, Mikrotik, deploy |
---
## PERTANYAAN SYNC (checklist AI)
```
1. Sinkronisasi Dokumentasi
- Pastikan seluruh keputusan, perubahan arsitektur, perubahan desain, perubahan implementasi, dan hasil diskusi pada sesi ini sudah didokumentasikan.
- Minimal periksa dan update:
- .cursor/rules/
- .cursor/*.md
- perintah.md (baris "Sync terakhir" di bawah)
- Architecture Decision Record (ADR) bila terdapat perubahan desain.
2. Sinkronisasi AI Context
- Project ini menggunakan folder .cursor sebagai single source of truth untuk seluruh AI Agent.
- Karena saya sering berpindah perangkat (PC, Laptop, Workstation), AI Agent tidak boleh bergantung pada riwayat percakapan.
- Seluruh konteks penting harus selalu tersimpan di repository.
3. Konsistensi Dokumentasi
- Pastikan tidak ada informasi yang hanya ada di percakapan tetapi belum terdokumentasi.
- Seluruh keputusan teknis wajib dipindahkan ke dokumentasi project.
- Percakapan bukan merupakan sumber dokumentasi.
- Repository adalah sumber dokumentasi utama.
- URL & indeks di perintah.md dan .cursor/me.md harus selaras.
4. Rules Update
- Apabila hasil diskusi mengubah cara pengembangan project, maka AI wajib memperbarui file rules yang relevan.
- atau membuat rules baru apabila diperlukan.
5. Dokumentasi Sebelum Coding
- Jika terdapat perubahan desain, arsitektur, atau workflow, dokumentasi harus diperbarui terlebih dahulu sebelum implementasi kode dilanjutkan.
- Documentation First, Code Second.
6. Final Checklist
- Sebelum menjawab "selesai", AI wajib melakukan checklist berikut:
- Semua keputusan pada percakapan ini sudah terdokumentasi.
- Semua file .cursor yang relevan telah diperbarui.
- Tidak ada konteks penting yang hanya tersimpan di percakapan.
- Dokumentasi konsisten dengan implementasi.
- Rules AI tetap menjadi acuan utama pengembangan.
- Project tetap dapat dipahami AI Agent meskipun riwayat chat tidak tersedia.
- Jika salah satu poin belum terpenuhi, jangan nyatakan pekerjaan selesai.
```
### Sync terakhir
| Tanggal | Ringkasan perubahan |
|---------|---------------------|
| 2026-06-11 | Lengkapi `me.md` & `perintah.md` — URL produksi, indeks file, selaraskan server-connection / GeoNetAgent |
| 2026-06-23 | Implementasi fitur Service Tokens di geonet-console (migration, model, service, API endpoints, UI, audit log) |
Loading…
Cancel
Save