new file: CHANGELOG.md

modified:   README.md
	modified:   support-provisioning-portal/assets/portal.css
	modified:   support-provisioning-portal/assets/portal.js
	modified:   support-provisioning-portal/includes/class-spp-activator.php
	modified:   support-provisioning-portal/includes/class-spp-admin-page.php
	modified:   support-provisioning-portal/includes/class-spp-http-proxmox-client.php
	modified:   support-provisioning-portal/includes/class-spp-mock-proxmox-client.php
	new file:   support-provisioning-portal/includes/class-spp-permissions.php
	modified:   support-provisioning-portal/includes/class-spp-plugin.php
	modified:   support-provisioning-portal/includes/class-spp-repository.php
	modified:   support-provisioning-portal/includes/class-spp-rest-controller.php
	modified:   support-provisioning-portal/includes/class-spp-shortcode.php
	modified:   support-provisioning-portal/includes/interface-spp-proxmox-client.php
	modified:   support-provisioning-portal/support-provisioning-portal.php
This commit is contained in:
Sven Steinert
2026-04-24 15:13:42 +02:00
parent aee79ddbfa
commit 2c1949bf1e
15 changed files with 1900 additions and 170 deletions

View File

@@ -11,7 +11,8 @@ final class SPP_REST_Controller
public function __construct(
private SPP_Repository $repository,
private SPP_Proxmox_Client $proxmox,
private SPP_Expiration_Service $expiration_service
private SPP_Expiration_Service $expiration_service,
private SPP_Permissions $permissions
) {
}
@@ -43,7 +44,7 @@ final class SPP_REST_Controller
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_create_deployments'],
],
]);
@@ -56,43 +57,97 @@ final class SPP_REST_Controller
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [$this, 'delete_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_delete_deployments'],
],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/shares', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'list_deployment_shares'],
'permission_callback' => [$this, 'can_read'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'share_deployment'],
'permission_callback' => [$this, 'can_read'],
],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/shares/(?P<user_id>\d+)', [
'methods' => WP_REST_Server::DELETABLE,
'callback' => [$this, 'unshare_deployment'],
'permission_callback' => [$this, 'can_read'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/start', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'start_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_start_deployments'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/stop', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'stop_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_stop_deployments'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/prolong', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'prolong_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_prolong_deployments'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/refresh-ips', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'refresh_deployment_ips'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_refresh_deployment_ips'],
]);
}
public function can_read(): bool
{
return is_user_logged_in() && current_user_can('read');
return is_user_logged_in() && $this->permissions->current_user_has(SPP_Permissions::VIEW_PORTAL);
}
public function can_mutate(): bool
public function can_create_deployments(): bool
{
return is_user_logged_in() && current_user_can('edit_posts');
return $this->can_use_portal_action(SPP_Permissions::CREATE_DEPLOYMENTS);
}
public function can_start_deployments(): bool
{
return $this->can_use_portal_action(SPP_Permissions::START_DEPLOYMENTS);
}
public function can_stop_deployments(): bool
{
return $this->can_use_portal_action(SPP_Permissions::STOP_DEPLOYMENTS);
}
public function can_prolong_deployments(): bool
{
return $this->can_use_portal_action(SPP_Permissions::PROLONG_DEPLOYMENTS);
}
public function can_refresh_deployment_ips(): bool
{
return $this->can_use_portal_action(SPP_Permissions::REFRESH_DEPLOYMENT_IPS);
}
public function can_delete_deployments(): bool
{
return $this->can_use_portal_action(SPP_Permissions::DELETE_DEPLOYMENTS);
}
private function can_use_portal_action(string $permission): bool
{
return $this->can_read() && $this->permissions->current_user_has($permission);
}
private function can_manage_all_deployments(): bool
{
return $this->permissions->current_user_has(SPP_Permissions::MANAGE_ALL_DEPLOYMENTS);
}
public function list_templates(): WP_REST_Response
@@ -113,19 +168,26 @@ final class SPP_REST_Controller
{
$this->sync_expirations();
return rest_ensure_response($this->repository->deployments());
return rest_ensure_response($this->repository->deployments(
get_current_user_id(),
$this->can_manage_all_deployments()
));
}
public function get_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$this->sync_expirations();
$deployment = $this->repository->deployment((int) $request['id']);
$deployment = $this->repository->deployment_for_user(
(int) $request['id'],
get_current_user_id(),
$this->can_manage_all_deployments()
);
if ($deployment === null) {
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
}
return rest_ensure_response($deployment);
return rest_ensure_response($this->deployment_response($deployment));
}
public function create_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
@@ -182,7 +244,13 @@ final class SPP_REST_Controller
return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
}
return new WP_REST_Response($deployment, 201);
$deployment = $this->repository->deployment_for_user(
(int) $deployment['id'],
get_current_user_id(),
$this->can_manage_all_deployments()
);
return new WP_REST_Response($this->deployment_response($deployment), 201);
}
private function validate_memory_quota(int $requested_memory_mb, int $actor_id): ?WP_Error
@@ -233,9 +301,13 @@ final class SPP_REST_Controller
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
}
if (!$this->repository->user_can_access_deployment($id, get_current_user_id(), $this->can_manage_all_deployments())) {
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
}
$ttl_hours = (int) $request->get_param('ttlHours');
$never_expire = (bool) $request->get_param('neverExpire');
$deployment = $this->repository->deployment($id);
$deployment = $this->repository->deployment_for_user($id, get_current_user_id(), $this->can_manage_all_deployments());
if ($deployment === null) {
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
@@ -252,13 +324,16 @@ final class SPP_REST_Controller
}
if ($record['status'] === 'EXPIRED') {
$quota_error = $this->validate_memory_quota((int) $deployment['memoryMb'], get_current_user_id());
$quota_error = $this->validate_memory_quota((int) $deployment['memoryMb'], (int) $record['requested_by']);
if ($quota_error instanceof WP_Error) {
return $quota_error;
}
}
return rest_ensure_response($this->repository->prolong_deployment($id, $ttl_hours, get_current_user_id()));
$this->repository->prolong_deployment($id, $ttl_hours, get_current_user_id());
$deployment = $this->repository->deployment_for_user($id, get_current_user_id(), $this->can_manage_all_deployments());
return rest_ensure_response($this->deployment_response($deployment));
}
public function refresh_deployment_ips(WP_REST_Request $request): WP_REST_Response|WP_Error
@@ -271,12 +346,16 @@ final class SPP_REST_Controller
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
}
if (!$this->repository->user_can_access_deployment($id, get_current_user_id(), $this->can_manage_all_deployments())) {
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
}
if (empty($record['proxmox_vm_id'])) {
return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]);
}
try {
$deployment = $this->repository->update_deployment_ips(
$this->repository->update_deployment_ips(
$id,
$this->proxmox->get_ip_addresses((int) $record['proxmox_vm_id']),
get_current_user_id()
@@ -285,7 +364,69 @@ final class SPP_REST_Controller
return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
}
return rest_ensure_response($deployment);
$deployment = $this->repository->deployment_for_user($id, get_current_user_id(), $this->can_manage_all_deployments());
return rest_ensure_response($this->deployment_response($deployment));
}
public function list_deployment_shares(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$this->sync_expirations();
$id = (int) $request['id'];
if (!$this->user_can_share_deployment($id)) {
return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can view shares.', ['status' => 403]);
}
return rest_ensure_response($this->repository->deployment_shares($id));
}
public function share_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$this->sync_expirations();
$id = (int) $request['id'];
if (!$this->user_can_share_deployment($id)) {
return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can share this deployment.', ['status' => 403]);
}
$identifier = sanitize_text_field((string) $request->get_param('user'));
$target = $this->find_share_target_user($identifier);
if (!$target instanceof WP_User) {
return new WP_Error('spp_user_not_found', 'User not found.', ['status' => 404]);
}
if ((int) $target->ID === get_current_user_id()) {
return new WP_Error('spp_invalid_share_target', 'You already have access to this deployment.', ['status' => 400]);
}
$record = $this->repository->deployment_record($id);
if ($record === null || (int) $record['requested_by'] === (int) $target->ID) {
return new WP_Error('spp_invalid_share_target', 'The owner already has access to this deployment.', ['status' => 400]);
}
if (!$this->permissions->user_has((int) $target->ID, SPP_Permissions::VIEW_PORTAL)) {
return new WP_Error('spp_user_without_portal_access', 'That user does not have portal access yet.', ['status' => 400]);
}
$this->repository->share_deployment($id, (int) $target->ID, get_current_user_id());
return rest_ensure_response($this->repository->deployment_shares($id));
}
public function unshare_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$this->sync_expirations();
$id = (int) $request['id'];
if (!$this->user_can_share_deployment($id)) {
return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can change shares.', ['status' => 403]);
}
$this->repository->unshare_deployment($id, (int) $request['user_id'], get_current_user_id());
return rest_ensure_response($this->repository->deployment_shares($id));
}
private function apply_lifecycle_action(int $id, string $status, string $audit_action, string $method): WP_REST_Response|WP_Error
@@ -297,6 +438,14 @@ final class SPP_REST_Controller
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
}
if (!$this->repository->user_can_access_deployment($id, get_current_user_id(), $this->can_manage_all_deployments())) {
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
}
if ($method === 'delete_vm' && !$this->user_can_delete_deployment($id)) {
return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can delete this deployment.', ['status' => 403]);
}
if ($method === 'start_vm' && $record['status'] === 'EXPIRED') {
return new WP_Error(
'spp_expired_deployment',
@@ -312,7 +461,7 @@ final class SPP_REST_Controller
try {
$this->proxmox->{$method}((int) $record['proxmox_vm_id']);
if ($method === 'start_vm') {
$deployment = $this->repository->update_deployment_status_and_ips(
$this->repository->update_deployment_status_and_ips(
$id,
$status,
$this->safe_ip_addresses((int) $record['proxmox_vm_id']),
@@ -320,13 +469,80 @@ final class SPP_REST_Controller
get_current_user_id()
);
} else {
$deployment = $this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_id());
$this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_id());
}
} catch (Throwable $error) {
return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
}
return rest_ensure_response($deployment);
if ($method === 'delete_vm') {
return rest_ensure_response(['deleted' => true]);
}
$deployment = $this->repository->deployment_for_user($id, get_current_user_id(), $this->can_manage_all_deployments());
return rest_ensure_response($this->deployment_response($deployment));
}
/**
* @param array<string, mixed>|null $deployment
* @return array<string, mixed>
*/
private function deployment_response(?array $deployment): array
{
if ($deployment === null) {
return [];
}
$id = (int) $deployment['id'];
$deployment['canShare'] = $this->user_can_share_deployment($id);
$deployment['canDelete'] = $this->user_can_delete_deployment($id);
if ($deployment['canShare']) {
$deployment['shares'] = $this->repository->deployment_shares($id);
}
return $deployment;
}
private function user_can_share_deployment(int $deployment_id): bool
{
$record = $this->repository->deployment_record($deployment_id);
if ($record === null || $record['status'] === 'DELETED') {
return false;
}
return $this->repository->user_owns_deployment($deployment_id, get_current_user_id())
|| $this->can_manage_all_deployments();
}
private function user_can_delete_deployment(int $deployment_id): bool
{
$record = $this->repository->deployment_record($deployment_id);
if ($record === null || $record['status'] === 'DELETED') {
return false;
}
return $this->permissions->current_user_has(SPP_Permissions::DELETE_DEPLOYMENTS)
&& (
$this->repository->user_owns_deployment($deployment_id, get_current_user_id())
|| $this->can_manage_all_deployments()
);
}
private function find_share_target_user(string $identifier): ?WP_User
{
if ($identifier === '') {
return null;
}
if (is_email($identifier)) {
$user = get_user_by('email', $identifier);
} else {
$user = get_user_by('login', $identifier);
}
return $user instanceof WP_User ? $user : null;
}
private function sync_expirations(): void