Browse Source
- 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
10 changed files with 1514 additions and 1 deletions
@ -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), |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -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…
Reference in new issue