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

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
## 0.6.0 - 2026-04-24
- Added deployment sharing with a dedicated `wp_spp_deployment_shares` table.
- Restricted regular deployment listing and detail access to owned or explicitly shared deployments.
- Added a `View and manage all deployments` plugin right for support/admin users who need global deployment access.
- Restricted delete actions to deployment owners or users with global deployment management.
- Added owner-only/global sharing controls to the deployment detail UI.
- Validated managed template VMIDs against the configured Proxmox QEMU template list before saving.
- Stopped rendering the saved Proxmox token secret back into the settings form.
- Enforced HTTPS for saved Proxmox base URLs in HTTP mode settings.
## 0.5.0 - 2026-04-24
- Added Proxmox QEMU template discovery through the configured Proxmox node.
- Added admin-panel template management for importing, editing, manually adding, and removing approved plugin templates.
- Added a dedicated `Manage templates` plugin right.
- Preserved historical deployment records by deactivating removed templates instead of deleting their rows.
- Stopped upgrade seeding from overwriting existing template rows, so admin-managed template policy stays intact.
- Documented how approved plugin templates relate to Proxmox QEMU templates.
## 0.4.0 - 2026-04-24
- Added plugin-owned per-user rights stored in user metadata.
- Added admin-panel user rights management with grant controls for portal access, lifecycle actions, settings, and user-rights management.
- Added RAM quota overrides to the same admin rights table so per-user access and contingents can be managed together.
- Replaced REST API `read` and `edit_posts` checks with plugin permission checks.
- Updated the portal UI to show create, lifecycle, refresh, delete, and prolong controls only when the current user has the matching plugin right.
- Switched Proxmox and quota settings saves to a plugin-permission protected admin handler.
- Added bootstrap access for WordPress administrators until the first plugin rights manager is assigned.
- Documented SSO identity-provider users and the new permission model in the README.

View File

@@ -13,6 +13,7 @@ The application runs as a WordPress plugin and exposes both:
- Custom database tables: - Custom database tables:
- `wp_spp_templates` - `wp_spp_templates`
- `wp_spp_deployments` - `wp_spp_deployments`
- `wp_spp_deployment_shares`
- `wp_spp_audit_logs` - `wp_spp_audit_logs`
- Seed data for 3 approved templates - Seed data for 3 approved templates
- WordPress REST API endpoints: - WordPress REST API endpoints:
@@ -25,6 +26,9 @@ The application runs as a WordPress plugin and exposes both:
- `POST /wp-json/support-provisioning/v1/deployments/:id/stop` - `POST /wp-json/support-provisioning/v1/deployments/:id/stop`
- `POST /wp-json/support-provisioning/v1/deployments/:id/prolong` - `POST /wp-json/support-provisioning/v1/deployments/:id/prolong`
- `POST /wp-json/support-provisioning/v1/deployments/:id/refresh-ips` - `POST /wp-json/support-provisioning/v1/deployments/:id/refresh-ips`
- `GET /wp-json/support-provisioning/v1/deployments/:id/shares`
- `POST /wp-json/support-provisioning/v1/deployments/:id/shares`
- `DELETE /wp-json/support-provisioning/v1/deployments/:id/shares/:user_id`
- `DELETE /wp-json/support-provisioning/v1/deployments/:id` - `DELETE /wp-json/support-provisioning/v1/deployments/:id`
- Mock Proxmox adapter for local/no-cluster use - Mock Proxmox adapter for local/no-cluster use
- HTTP token-auth Proxmox adapter behind the same interface - HTTP token-auth Proxmox adapter behind the same interface
@@ -34,6 +38,10 @@ The application runs as a WordPress plugin and exposes both:
- IP address display for deployments when available from the mock adapter or Proxmox guest agent - IP address display for deployments when available from the mock adapter or Proxmox guest agent
- Manual IP refresh action for deployments - Manual IP refresh action for deployments
- Non-destructive expiration: expired VMs are stopped and locked, not deleted - Non-destructive expiration: expired VMs are stopped and locked, not deleted
- Plugin-owned per-user rights for portal access, lifecycle actions, settings, and user-rights management
- Admin-panel user rights management for WordPress and SSO-created users
- Admin-panel template management for importing QEMU templates from the configured Proxmox node
- Owner/private deployment visibility with explicit per-deployment sharing
- Minimal admin/frontend UI for: - Minimal admin/frontend UI for:
- deployment dashboard - deployment dashboard
- templates - templates
@@ -63,10 +71,36 @@ The application runs as a WordPress plugin and exposes both:
## Permissions ## Permissions
- Logged-in users with `read` can view templates and deployments. - The plugin uses its own per-user rights stored in user metadata, not WordPress roles.
- Logged-in users with `edit_posts` can create, start, stop, and delete deployments. - SSO users from external identity providers can be granted access after their first WordPress sign-in creates or maps their WordPress user.
- Available plugin rights:
- Open portal and view deployments
- Create deployments
- Start deployments
- Stop deployments
- Prolong deployments
- Refresh IP addresses
- Delete deployments
- View and manage all deployments
- Manage templates
- Manage Proxmox settings and quotas
- Manage user rights
- Until the first user is granted **Manage user rights**, WordPress users with `manage_options` have bootstrap access so the first plugin rights manager can be assigned.
- After that bootstrap, portal and lifecycle access are controlled by the plugin rights on each user.
- WordPress REST nonces are required for UI mutations. - WordPress REST nonces are required for UI mutations.
## Deployment Visibility And Sharing
Deployments are private by default:
- Regular portal users see deployments they created.
- Users can also see deployments explicitly shared with them.
- Users with **View and manage all deployments** can see and operate on all deployments.
The deployment owner, or a user with **View and manage all deployments**, can share a deployment from its detail view by entering another user's WordPress login or email address. The target user must already have **Open portal and view deployments**.
Shared users can use lifecycle actions only when they also have the matching plugin right, such as **Start deployments** or **Stop deployments**. Shared users cannot delete someone else's deployment unless they also have **View and manage all deployments**.
## Expiration And Contingents ## Expiration And Contingents
Deployment creation supports either a TTL in hours or **Never expire**. Deployment creation supports either a TTL in hours or **Never expire**.
@@ -80,14 +114,14 @@ When a deployment reaches its TTL:
- The user can either prolong the TTL or delete the deployment. - The user can either prolong the TTL or delete the deployment.
- Deleted deployments are hidden from the active deployment list, but audit rows remain. - Deleted deployments are hidden from the active deployment list, but audit rows remain.
RAM contingents are configured from **Support Provisioning > Proxmox Settings**: RAM contingents are configured from the **Proxmox Settings** section on the **Support Provisioning** admin page:
- Default per-user RAM limit (MB) - Default per-user RAM limit (MB)
- Global RAM limit (MB) - Global RAM limit (MB)
Set either value to `0` for unlimited. Active allocations include deployments in `PROVISIONING`, `STOPPED`, `RUNNING`, and `DELETING` states. Set either value to `0` for unlimited. Active allocations include deployments in `PROVISIONING`, `STOPPED`, `RUNNING`, and `DELETING` states.
Per-user overrides are available on each WordPress user profile under **Support Provisioning Contingent**. Leave the override empty to use the default per-user limit. Per-user overrides are available in the **User Rights** section on the **Support Provisioning** admin page. Leave the override empty to use the default per-user limit.
## IP Addresses ## IP Addresses
@@ -100,9 +134,9 @@ If the guest agent reports IPs only after boot, use **Refresh IPs** in the deplo
The plugin defaults to mock mode. Configure live Proxmox access from the **Support Provisioning** admin page: The plugin defaults to mock mode. Configure live Proxmox access from the **Support Provisioning** admin page:
- Mode: `Mock` or `HTTP token auth` - Mode: `Mock` or `HTTP token auth`
- Base URL, for example `https://proxmox.example.internal:8006` - Base URL, for example `https://proxmox.example.internal:8006`. HTTP mode requires an HTTPS URL.
- Token ID, for example `user@realm!token-name` - Token ID, for example `user@realm!token-name`
- Token Secret - Token Secret. The saved secret is not rendered back into the settings form; leave the field blank to keep it unchanged.
- Node, for example `pve-01` - Node, for example `pve-01`
No Proxmox secrets are committed to the repository. No Proxmox secrets are committed to the repository.
@@ -113,9 +147,18 @@ Use this section when moving from mock mode to a real Proxmox VE node.
### 1. Prepare Proxmox Templates ### 1. Prepare Proxmox Templates
The plugin only provisions from approved templates. For a first real test, either create Proxmox VM templates with the seeded IDs or update the plugin database rows to match your real template IDs. The plugin only provisions from approved templates. Proxmox remains the source for actual VM templates, while the plugin stores an approved template policy row with display name, OS type, CPU, RAM, disk, default TTL, and Proxmox template VMID.
Seeded template IDs: Open the **Templates** section on the **Support Provisioning** admin page to:
- see approved plugin templates
- import QEMU templates from the configured Proxmox node
- edit the policy values used during provisioning
- remove templates from new provisioning without breaking historical deployment records
The plugin lists QEMU templates from the configured node via the Proxmox API. It does not provision from LXC templates.
Seeded example template IDs:
| Plugin template | Proxmox template VMID | | Plugin template | Proxmox template VMID |
| --- | ---: | | --- | ---: |
@@ -123,26 +166,16 @@ Seeded template IDs:
| Windows Support Client | `9002` | | Windows Support Client | `9002` |
| Linux Utility VM | `9003` | | Linux Utility VM | `9003` |
The fastest first test is to create one real Proxmox template with VMID `9003`, then use the **Linux Utility VM** option in the plugin. The fastest first test is to create one real Proxmox QEMU template, then import it from **Support Provisioning > Templates**.
Template requirements: Template requirements:
- The VM must be converted to a Proxmox template. - The VM must be converted to a Proxmox template.
- The template must exist on the Proxmox node configured in the plugin. - The template must exist on the Proxmox node configured in the plugin.
- The plugin currently performs a full clone (`full=1`), so the target storage must have enough free capacity. - The plugin currently performs a full clone (`full=1`), so the target storage must have enough free capacity.
- CPU and memory are set by the plugin after clone based on the template policy row. - CPU and memory are set by the plugin after clone based on the approved template policy row.
- For IP address display, install and enable `qemu-guest-agent` inside the guest and enable the guest agent option on the VM/template. - For IP address display, install and enable `qemu-guest-agent` inside the guest and enable the guest agent option on the VM/template.
If you do not want to use VMIDs `9001`, `9002`, or `9003`, update the template rows after activation:
```sql
UPDATE wp_spp_templates
SET proxmox_template_id = 1234
WHERE template_key = 'linux-utility-vm';
```
Replace `wp_` with your WordPress table prefix.
### 2. Create A Dedicated Proxmox API User ### 2. Create A Dedicated Proxmox API User
In Proxmox, create a dedicated user instead of using `root@pam` for the plugin. In Proxmox, create a dedicated user instead of using `root@pam` for the plugin.
@@ -180,6 +213,7 @@ Proxmox permissions are path based. For a first controlled test, grant the token
The plugin needs permissions for: The plugin needs permissions for:
- getting the next VMID - getting the next VMID
- listing QEMU templates on the configured node
- cloning a template VM - cloning a template VM
- changing CPU and memory after clone - changing CPU and memory after clone
- starting and stopping VMs - starting and stopping VMs
@@ -225,7 +259,7 @@ If Proxmox still uses its default self-signed certificate, WordPress may reject
### 6. Configure WordPress Plugin Settings ### 6. Configure WordPress Plugin Settings
In WordPress admin, open **Support Provisioning > Proxmox Settings**: In WordPress admin, open **Support Provisioning** and use the **Proxmox Settings** section:
| Setting | Value | | Setting | Value |
| --- | --- | | --- | --- |
@@ -279,7 +313,7 @@ Then test in this order:
- Resource limits come from approved templates, not user input. - Resource limits come from approved templates, not user input.
- Deployment lifecycle operations are routed through a dedicated Proxmox client interface. - Deployment lifecycle operations are routed through a dedicated Proxmox client interface.
- Live Proxmox access can be replaced or expanded without changing REST or UI code. - Live Proxmox access can be replaced or expanded without changing REST or UI code.
- WordPress users are used as actors for audit logging. This keeps the first slice simple and leaves room for later RBAC/SSO mapping. - WordPress users are used as actors for audit logging. Portal permissions are assigned per user inside the plugin so users created by SSO or external identity providers do not need WordPress author/editor roles.
## Original Bootstrap Brief ## Original Bootstrap Brief

View File

@@ -16,6 +16,11 @@
align-items: start; align-items: start;
} }
.spp-admin-stack {
display: grid;
gap: 20px;
}
.spp-settings, .spp-settings,
.spp-panel, .spp-panel,
.spp-card { .spp-card {
@@ -28,6 +33,14 @@
padding: 18px; padding: 18px;
} }
.spp-admin-notice {
padding: 16px;
}
.spp-admin-notice p {
margin: 0;
}
.spp-settings label { .spp-settings label {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -37,6 +50,7 @@
.spp-settings input, .spp-settings input,
.spp-settings select, .spp-settings select,
.spp-settings textarea,
.spp-input, .spp-input,
.spp-select { .spp-select {
min-height: 38px; min-height: 38px;
@@ -48,6 +62,10 @@
padding: 7px 10px; padding: 7px 10px;
} }
.spp-settings textarea {
min-height: 72px;
}
.spp-header { .spp-header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -166,6 +184,21 @@
color: #92400e; color: #92400e;
} }
.spp-badge.OWNER {
background: #dbeafe;
color: #1e40af;
}
.spp-badge.SHARED {
background: #ede9fe;
color: #5b21b6;
}
.spp-badge.ADMIN {
background: #e0f2fe;
color: #075985;
}
.spp-badge.FAILED, .spp-badge.FAILED,
.spp-error { .spp-error {
background: #fee2e2; background: #fee2e2;
@@ -242,8 +275,51 @@
margin: 0; margin: 0;
} }
.spp-share-panel {
border-top: 1px solid var(--spp-line);
display: grid;
gap: 12px;
margin-top: 18px;
max-width: 640px;
padding-top: 16px;
}
.spp-share-panel h3,
.spp-share-panel p {
margin: 0;
}
.spp-share-list {
display: grid;
gap: 8px;
}
.spp-share-row {
align-items: center;
border: 1px solid var(--spp-line);
border-radius: 6px;
display: flex;
gap: 10px;
justify-content: space-between;
padding: 10px;
}
.spp-share-row small {
color: var(--spp-muted);
display: block;
margin-top: 2px;
}
.spp-share-form {
align-items: end;
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
}
.spp-form label, .spp-form label,
.spp-prolong-form label { .spp-prolong-form label,
.spp-share-form label {
display: grid; display: grid;
gap: 6px; gap: 6px;
font-weight: 700; font-weight: 700;
@@ -264,9 +340,148 @@
padding: 10px 12px; padding: 10px 12px;
} }
.spp-user-access {
margin-top: 20px;
}
.spp-template-admin {
margin-top: 20px;
}
.spp-template-admin h3 {
margin: 18px 0 10px;
}
.spp-section-header {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
margin-bottom: 14px;
}
.spp-section-header h2 {
margin: 0 0 4px;
}
.spp-user-search {
align-items: center;
display: flex;
gap: 8px;
}
.spp-user-search input {
min-height: 32px;
}
.spp-user-access-table td {
vertical-align: top;
}
.spp-user-login {
color: var(--spp-muted);
display: block;
margin-top: 4px;
}
.spp-permission-groups {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.spp-permission-groups fieldset {
border: 1px solid var(--spp-line);
border-radius: 6px;
margin: 0;
padding: 10px;
}
.spp-permission-groups legend {
font-weight: 700;
padding: 0 4px;
}
.spp-permission-groups label {
align-items: flex-start;
display: flex !important;
font-weight: 400;
gap: 6px;
margin: 8px 0 0;
}
.spp-permission-groups input[type="checkbox"] {
margin: 2px 0 0;
min-height: auto;
padding: 0;
}
.spp-template-list,
.spp-pve-template-grid {
display: grid;
gap: 12px;
}
.spp-pve-template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.spp-template-row,
.spp-pve-template {
border: 1px solid var(--spp-line);
border-radius: 6px;
display: grid;
gap: 12px;
margin: 0;
padding: 14px;
}
.spp-template-row-head {
align-items: flex-start;
display: flex;
gap: 10px;
justify-content: space-between;
}
.spp-template-row-head strong,
.spp-template-row-head span {
display: block;
}
.spp-template-row-head span {
color: var(--spp-muted);
font-size: 13px;
margin-top: 2px;
}
.spp-template-fields {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.spp-template-fields label {
margin: 0;
}
.spp-template-description {
grid-column: 1 / -1;
}
.spp-template-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (max-width: 960px) { @media (max-width: 960px) {
.spp-admin-grid, .spp-admin-grid,
.spp-grid { .spp-grid,
.spp-permission-groups,
.spp-pve-template-grid,
.spp-template-fields,
.spp-share-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }

View File

@@ -4,6 +4,8 @@
roots.forEach((root) => { roots.forEach((root) => {
const api = root.dataset.restUrl; const api = root.dataset.restUrl;
const nonce = root.dataset.nonce; const nonce = root.dataset.nonce;
const permissions = new Set(JSON.parse(root.dataset.permissions || "[]"));
const can = (permission) => permissions.has(permission);
let state = { let state = {
view: "deployments", view: "deployments",
deployments: [], deployments: [],
@@ -102,8 +104,58 @@
render(); render();
}; };
const shareDeployment = async (event, id) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await request(`/deployments/${id}/shares`, {
method: "POST",
body: JSON.stringify({
user: String(form.get("user"))
})
});
state.selectedDeployment = await request(`/deployments/${id}`);
state.error = null;
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const removeShare = async (id, userId) => {
try {
await request(`/deployments/${id}/shares/${userId}`, { method: "DELETE" });
state.selectedDeployment = await request(`/deployments/${id}`);
state.error = null;
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const statusBadge = (status) => `<span class="spp-badge ${status}">${status.replace("_", " ")}</span>`; const statusBadge = (status) => `<span class="spp-badge ${status}">${status.replace("_", " ")}</span>`;
const accessLabel = (deployment) => {
if (deployment.accessType === "shared") {
return '<span class="spp-badge SHARED">SHARED</span>';
}
if (deployment.accessType === "admin") {
return '<span class="spp-badge ADMIN">ADMIN</span>';
}
return '<span class="spp-badge OWNER">OWNER</span>';
};
const actionButton = (permission, action, label, id, className = "", disabled = false) => {
if (!can(permission)) {
return "";
}
return `<button class="spp-button ${className}" data-action="${action}" data-id="${id}" ${disabled ? "disabled" : ""} type="button">${label}</button>`;
};
const ipList = (ips) => { const ipList = (ips) => {
if (!Array.isArray(ips) || ips.length === 0) { if (!Array.isArray(ips) || ips.length === 0) {
return "Pending"; return "Pending";
@@ -149,7 +201,7 @@
<div class="spp-tabs"> <div class="spp-tabs">
<button class="spp-button" data-view="deployments" type="button">Deployments</button> <button class="spp-button" data-view="deployments" type="button">Deployments</button>
<button class="spp-button" data-view="templates" type="button">Templates</button> <button class="spp-button" data-view="templates" type="button">Templates</button>
<button class="spp-button spp-button-primary" data-view="create" type="button">Create</button> ${can("create_deployments") ? '<button class="spp-button spp-button-primary" data-view="create" type="button">Create</button>' : ""}
</div> </div>
</div> </div>
`; `;
@@ -163,13 +215,14 @@
<div class="spp-panel"> <div class="spp-panel">
<table class="spp-table"> <table class="spp-table">
<thead> <thead>
<tr><th>Name</th><th>Status</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr> <tr><th>Name</th><th>Status</th><th>Access</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
${state.deployments.map((deployment) => ` ${state.deployments.map((deployment) => `
<tr> <tr>
<td><strong>${escapeHtml(deployment.name)}</strong></td> <td><strong>${escapeHtml(deployment.name)}</strong></td>
<td>${statusBadge(deployment.status)}</td> <td>${statusBadge(deployment.status)}</td>
<td>${accessLabel(deployment)}</td>
<td>${escapeHtml(deployment.templateName)}</td> <td>${escapeHtml(deployment.templateName)}</td>
<td>${ipList(deployment.ipAddresses)}</td> <td>${ipList(deployment.ipAddresses)}</td>
<td>${dateTime(deployment.expiresAt)}</td> <td>${dateTime(deployment.expiresAt)}</td>
@@ -200,28 +253,34 @@
</div> </div>
`; `;
const createView = () => ` const createView = () => {
<div class="spp-panel" style="padding:16px;"> if (!can("create_deployments")) {
<form class="spp-form" id="spp-create-form"> return '<div class="spp-panel"><p style="padding:16px;margin:0;">You do not have permission to create deployments.</p></div>';
<label>Template }
<select class="spp-select" name="templateId" required>
${state.templates.map((template) => `<option value="${template.id}">${escapeHtml(template.name)}</option>`).join("")} return `
</select> <div class="spp-panel" style="padding:16px;">
</label> <form class="spp-form" id="spp-create-form">
<label>Deployment name <label>Template
<input class="spp-input" name="name" minlength="3" maxlength="160" required placeholder="pbx-repro-case-1842"> <select class="spp-select" name="templateId" required>
</label> ${state.templates.map((template) => `<option value="${template.id}">${escapeHtml(template.name)}</option>`).join("")}
<label>TTL hours </select>
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Use template default"> </label>
</label> <label>Deployment name
<label class="spp-check"> <input class="spp-input" name="name" minlength="3" maxlength="160" required placeholder="pbx-repro-case-1842">
<input name="neverExpire" type="checkbox" value="1"> </label>
<span>Never expire</span> <label>TTL hours
</label> <input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Use template default">
<button class="spp-button spp-button-primary" type="submit">Create Deployment</button> </label>
</form> <label class="spp-check">
</div> <input name="neverExpire" type="checkbox" value="1">
`; <span>Never expire</span>
</label>
<button class="spp-button spp-button-primary" type="submit">Create Deployment</button>
</form>
</div>
`;
};
const detailView = () => { const detailView = () => {
const deployment = state.selectedDeployment; const deployment = state.selectedDeployment;
@@ -229,6 +288,8 @@
return deploymentsView(); return deploymentsView();
} }
const shares = Array.isArray(deployment.shares) ? deployment.shares : [];
return ` return `
<div class="spp-panel" style="padding:16px;"> <div class="spp-panel" style="padding:16px;">
${deployment.status === "EXPIRED" ? ` ${deployment.status === "EXPIRED" ? `
@@ -244,15 +305,16 @@
${statusBadge(deployment.status)} ${statusBadge(deployment.status)}
</div> </div>
<div class="spp-actions"> <div class="spp-actions">
<button class="spp-button spp-button-primary" data-action="start" data-id="${deployment.id}" ${deployment.status === "RUNNING" || deployment.status === "EXPIRED" ? "disabled" : ""} type="button">Start</button> ${actionButton("start_deployments", "start", "Start", deployment.id, "spp-button-primary", deployment.status === "RUNNING" || deployment.status === "EXPIRED")}
<button class="spp-button" data-action="stop" data-id="${deployment.id}" ${deployment.status === "STOPPED" || deployment.status === "EXPIRED" ? "disabled" : ""} type="button">Stop</button> ${actionButton("stop_deployments", "stop", "Stop", deployment.id, "", deployment.status === "STOPPED" || deployment.status === "EXPIRED")}
<button class="spp-button" data-action="refresh-ips" data-id="${deployment.id}" ${deployment.status === "DELETED" ? "disabled" : ""} type="button">Refresh IPs</button> ${actionButton("refresh_deployment_ips", "refresh-ips", "Refresh IPs", deployment.id, "", deployment.status === "DELETED")}
<button class="spp-button spp-button-danger" data-action="delete" data-id="${deployment.id}" ${deployment.status === "DELETED" ? "disabled" : ""} type="button">Delete</button> ${deployment.canDelete ? actionButton("delete_deployments", "delete", "Delete", deployment.id, "spp-button-danger", deployment.status === "DELETED") : ""}
</div> </div>
</div> </div>
<div class="spp-meta"> <div class="spp-meta">
<span>Template<strong>${escapeHtml(deployment.templateName)}</strong></span> <span>Template<strong>${escapeHtml(deployment.templateName)}</strong></span>
<span>Requested by<strong>${escapeHtml(deployment.requestedByName)}</strong></span> <span>Requested by<strong>${escapeHtml(deployment.requestedByName)}</strong></span>
<span>Access<strong>${deployment.accessType ? escapeHtml(deployment.accessType) : "Owner"}</strong></span>
<span>Proxmox VM ID<strong>${deployment.proxmoxVmId || "Pending"}</strong></span> <span>Proxmox VM ID<strong>${deployment.proxmoxVmId || "Pending"}</strong></span>
<span>IP addresses<strong>${ipList(deployment.ipAddresses)}</strong></span> <span>IP addresses<strong>${ipList(deployment.ipAddresses)}</strong></span>
<span>CPU<strong>${deployment.cpuCores} cores</strong></span> <span>CPU<strong>${deployment.cpuCores} cores</strong></span>
@@ -261,17 +323,41 @@
<span>Created<strong>${dateTime(deployment.createdAt)}</strong></span> <span>Created<strong>${dateTime(deployment.createdAt)}</strong></span>
<span>Expires<strong>${dateTime(deployment.expiresAt)}</strong></span> <span>Expires<strong>${dateTime(deployment.expiresAt)}</strong></span>
</div> </div>
<form class="spp-prolong-form" data-prolong-form="${deployment.id}"> ${can("prolong_deployments") ? `
<h3>Prolong TTL</h3> <form class="spp-prolong-form" data-prolong-form="${deployment.id}">
<label>TTL hours <h3>Prolong TTL</h3>
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Hours from now"> <label>TTL hours
</label> <input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Hours from now">
<label class="spp-check"> </label>
<input name="neverExpire" type="checkbox" value="1"> <label class="spp-check">
<span>Never expire</span> <input name="neverExpire" type="checkbox" value="1">
</label> <span>Never expire</span>
<button class="spp-button spp-button-primary" type="submit">Prolong</button> </label>
</form> <button class="spp-button spp-button-primary" type="submit">Prolong</button>
</form>
` : ""}
${deployment.canShare ? `
<div class="spp-share-panel">
<h3>Shared Access</h3>
<div class="spp-share-list">
${shares.length === 0 ? '<p>No users have shared access.</p>' : shares.map((share) => `
<div class="spp-share-row">
<span>
<strong>${escapeHtml(share.displayName || share.userLogin)}</strong>
<small>${escapeHtml(share.userLogin)}${share.userEmail ? ` - ${escapeHtml(share.userEmail)}` : ""}</small>
</span>
<button class="spp-button" data-remove-share="${share.id}" data-id="${deployment.id}" type="button">Remove</button>
</div>
`).join("")}
</div>
<form class="spp-share-form" data-share-form="${deployment.id}">
<label>Share with user login or email
<input class="spp-input" name="user" required>
</label>
<button class="spp-button spp-button-primary" type="submit">Share</button>
</form>
</div>
` : ""}
</div> </div>
`; `;
}; };
@@ -310,6 +396,10 @@
button.addEventListener("click", () => lifecycle(Number(button.dataset.id), button.dataset.action)); button.addEventListener("click", () => lifecycle(Number(button.dataset.id), button.dataset.action));
}); });
root.querySelectorAll("[data-remove-share]").forEach((button) => {
button.addEventListener("click", () => removeShare(Number(button.dataset.id), Number(button.dataset.removeShare)));
});
const form = root.querySelector("#spp-create-form"); const form = root.querySelector("#spp-create-form");
if (form) { if (form) {
form.addEventListener("submit", createDeployment); form.addEventListener("submit", createDeployment);
@@ -338,6 +428,10 @@
}); });
} }
}); });
root.querySelectorAll("[data-share-form]").forEach((form) => {
form.addEventListener("submit", (event) => shareDeployment(event, Number(form.dataset.shareForm)));
});
}; };
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({ const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({

View File

@@ -6,7 +6,7 @@ if (!defined('ABSPATH')) {
final class SPP_Activator final class SPP_Activator
{ {
private const DB_VERSION = '0.3.0'; private const DB_VERSION = '0.6.0';
public static function activate(): void public static function activate(): void
{ {
@@ -50,6 +50,7 @@ final class SPP_Activator
$charset_collate = $wpdb->get_charset_collate(); $charset_collate = $wpdb->get_charset_collate();
$templates = self::table('templates'); $templates = self::table('templates');
$deployments = self::table('deployments'); $deployments = self::table('deployments');
$deployment_shares = self::table('deployment_shares');
$audit_logs = self::table('audit_logs'); $audit_logs = self::table('audit_logs');
dbDelta("CREATE TABLE {$templates} ( dbDelta("CREATE TABLE {$templates} (
@@ -96,6 +97,18 @@ final class SPP_Activator
$wpdb->query("ALTER TABLE {$deployments} ADD COLUMN ip_addresses longtext NULL AFTER proxmox_vm_id"); $wpdb->query("ALTER TABLE {$deployments} ADD COLUMN ip_addresses longtext NULL AFTER proxmox_vm_id");
} }
dbDelta("CREATE TABLE {$deployment_shares} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
deployment_id bigint(20) unsigned NOT NULL,
user_id bigint(20) unsigned NOT NULL,
created_by bigint(20) unsigned NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY deployment_user (deployment_id, user_id),
KEY deployment_id (deployment_id),
KEY user_id (user_id)
) {$charset_collate};");
dbDelta("CREATE TABLE {$audit_logs} ( dbDelta("CREATE TABLE {$audit_logs} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT, id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
action varchar(80) NOT NULL, action varchar(80) NOT NULL,
@@ -178,7 +191,6 @@ final class SPP_Activator
]); ]);
if ($exists > 0) { if ($exists > 0) {
$wpdb->update($table, $data, ['id' => $exists]);
continue; continue;
} }

View File

@@ -6,15 +6,20 @@ if (!defined('ABSPATH')) {
final class SPP_Admin_Page final class SPP_Admin_Page
{ {
public function __construct(
private SPP_Repository $repository,
private SPP_Permissions $permissions,
private SPP_Proxmox_Client $proxmox
) {
}
public function register_hooks(): void public function register_hooks(): void
{ {
add_action('admin_menu', [$this, 'register_menu']); add_action('admin_menu', [$this, 'register_menu']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
add_action('show_user_profile', [$this, 'render_user_quota_field']); add_action('admin_post_spp_save_settings', [$this, 'save_settings']);
add_action('edit_user_profile', [$this, 'render_user_quota_field']); add_action('admin_post_spp_save_user_access', [$this, 'save_user_access']);
add_action('personal_options_update', [$this, 'save_user_quota_field']); add_action('admin_post_spp_save_template', [$this, 'save_template']);
add_action('edit_user_profile_update', [$this, 'save_user_quota_field']);
} }
public function register_menu(): void public function register_menu(): void
@@ -22,7 +27,7 @@ final class SPP_Admin_Page
add_menu_page( add_menu_page(
'Support Provisioning', 'Support Provisioning',
'Support Provisioning', 'Support Provisioning',
'edit_posts', 'exist',
'support-provisioning-portal', 'support-provisioning-portal',
[$this, 'render'], [$this, 'render'],
'dashicons-cloud', 'dashicons-cloud',
@@ -30,29 +35,6 @@ final class SPP_Admin_Page
); );
} }
public function register_settings(): void
{
register_setting('spp_settings', 'spp_proxmox_mode', [
'type' => 'string',
'sanitize_callback' => static fn($value) => $value === 'http' ? 'http' : 'mock',
'default' => 'mock',
]);
register_setting('spp_settings', 'spp_proxmox_base_url', ['type' => 'string', 'sanitize_callback' => 'esc_url_raw']);
register_setting('spp_settings', 'spp_proxmox_token_id', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']);
register_setting('spp_settings', 'spp_proxmox_token_secret', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']);
register_setting('spp_settings', 'spp_proxmox_node', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'pve-01']);
register_setting('spp_settings', 'spp_quota_user_memory_mb', [
'type' => 'integer',
'sanitize_callback' => static fn($value) => max(0, absint($value)),
'default' => 0,
]);
register_setting('spp_settings', 'spp_quota_global_memory_mb', [
'type' => 'integer',
'sanitize_callback' => static fn($value) => max(0, absint($value)),
'default' => 0,
]);
}
public function enqueue_assets(string $hook): void public function enqueue_assets(string $hook): void
{ {
if ($hook !== 'toplevel_page_support-provisioning-portal') { if ($hook !== 'toplevel_page_support-provisioning-portal') {
@@ -65,17 +47,81 @@ final class SPP_Admin_Page
public function render(): void public function render(): void
{ {
if (!current_user_can('edit_posts')) { if (!$this->permissions->current_user_has_any()) {
wp_die(esc_html__('You do not have permission to access this page.', 'support-provisioning-portal')); wp_die(esc_html__('You do not have permission to access this page.', 'support-provisioning-portal'));
} }
$can_view_portal = $this->permissions->current_user_has(SPP_Permissions::VIEW_PORTAL);
$can_manage_templates = $this->permissions->current_user_has(SPP_Permissions::MANAGE_TEMPLATES);
$can_manage_settings = $this->permissions->current_user_has(SPP_Permissions::MANAGE_SETTINGS);
$can_manage_permissions = $this->permissions->current_user_has(SPP_Permissions::MANAGE_PERMISSIONS);
?> ?>
<div class="wrap spp-admin-wrap"> <div class="wrap spp-admin-wrap">
<h1><?php echo esc_html__('Support Provisioning Portal', 'support-provisioning-portal'); ?></h1> <h1><?php echo esc_html__('Support Provisioning Portal', 'support-provisioning-portal'); ?></h1>
<div class="spp-admin-grid"> <?php $this->render_notices(); ?>
<?php $this->render_app_root(); ?> <div class="<?php echo esc_attr($can_view_portal && $can_manage_settings ? 'spp-admin-grid' : 'spp-admin-stack'); ?>">
<?php $this->render_settings(); ?> <div>
<?php
if ($can_view_portal) {
$this->render_app_root();
} else {
$this->render_admin_only_notice();
}
?>
</div>
<?php if ($can_manage_settings) : ?>
<div class="spp-admin-side">
<?php $this->render_settings(); ?>
</div>
<?php endif; ?>
</div> </div>
<?php
if ($can_manage_templates) {
$this->render_template_management();
}
if ($can_manage_permissions) {
$this->render_user_access_management();
}
?>
</div>
<?php
}
private function render_notices(): void
{
$notice = isset($_GET['spp_notice']) ? sanitize_key((string) wp_unslash($_GET['spp_notice'])) : '';
if ($notice === '') {
return;
}
$messages = [
'settings_saved' => __('Settings saved.', 'support-provisioning-portal'),
'template_saved' => __('Template saved.', 'support-provisioning-portal'),
'template_removed' => __('Template removed from new provisioning.', 'support-provisioning-portal'),
'template_error' => __('Template could not be saved. Check the fields and confirm the VMID exists as a Proxmox QEMU template on the configured node.', 'support-provisioning-portal'),
'user_access_saved' => __('User rights saved.', 'support-provisioning-portal'),
'manager_required' => __('At least one user must keep the Manage user rights permission.', 'support-provisioning-portal'),
];
if (!isset($messages[$notice])) {
return;
}
$class = in_array($notice, ['manager_required', 'template_error'], true) ? 'notice notice-error' : 'notice notice-success';
printf(
'<div class="%s"><p>%s</p></div>',
esc_attr($class),
esc_html($messages[$notice])
);
}
private function render_admin_only_notice(): void
{
?>
<div class="spp-panel spp-admin-notice">
<p><?php echo esc_html__('Portal access is not enabled for your user. Administrative sections available to you are shown on this page.', 'support-provisioning-portal'); ?></p>
</div> </div>
<?php <?php
} }
@@ -88,6 +134,7 @@ final class SPP_Admin_Page
class="spp-portal" class="spp-portal"
data-rest-url="<?php echo esc_url_raw(rest_url('support-provisioning/v1')); ?>" data-rest-url="<?php echo esc_url_raw(rest_url('support-provisioning/v1')); ?>"
data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>"
data-permissions="<?php echo esc_attr((string) wp_json_encode($this->permissions->current_user_permissions())); ?>"
></div> ></div>
<?php <?php
} }
@@ -95,9 +142,10 @@ final class SPP_Admin_Page
private function render_settings(): void private function render_settings(): void
{ {
?> ?>
<form class="spp-settings" method="post" action="options.php"> <form class="spp-settings" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_settings">
<?php wp_nonce_field('spp_save_settings'); ?>
<h2><?php echo esc_html__('Proxmox Settings', 'support-provisioning-portal'); ?></h2> <h2><?php echo esc_html__('Proxmox Settings', 'support-provisioning-portal'); ?></h2>
<?php settings_fields('spp_settings'); ?>
<label> <label>
<span>Mode</span> <span>Mode</span>
<select name="spp_proxmox_mode"> <select name="spp_proxmox_mode">
@@ -115,7 +163,7 @@ final class SPP_Admin_Page
</label> </label>
<label> <label>
<span>Token Secret</span> <span>Token Secret</span>
<input name="spp_proxmox_token_secret" type="password" value="<?php echo esc_attr(get_option('spp_proxmox_token_secret', '')); ?>"> <input name="spp_proxmox_token_secret" type="password" value="" placeholder="Leave blank to keep existing secret">
</label> </label>
<label> <label>
<span>Node</span> <span>Node</span>
@@ -136,51 +184,515 @@ final class SPP_Admin_Page
<?php <?php
} }
public function render_user_quota_field(WP_User $user): void private function render_template_management(): void
{ {
if (!current_user_can('edit_users')) { $approved_templates = $this->repository->admin_templates();
return; $active_proxmox_ids = $this->repository->active_proxmox_template_ids();
} $proxmox_error = null;
$quota = get_user_meta($user->ID, 'spp_memory_quota_mb', true); try {
$proxmox_templates = $this->proxmox->list_templates();
} catch (Throwable $error) {
$proxmox_templates = [];
$proxmox_error = $error->getMessage();
}
?> ?>
<h2><?php echo esc_html__('Support Provisioning Contingent', 'support-provisioning-portal'); ?></h2> <section class="spp-settings spp-template-admin">
<table class="form-table" role="presentation"> <div class="spp-section-header">
<tr> <div>
<th><label for="spp_memory_quota_mb">RAM limit override (MB)</label></th> <h2><?php echo esc_html__('Templates', 'support-provisioning-portal'); ?></h2>
<td> <p class="description"><?php echo esc_html__('Approved templates are stored by the plugin and used for new deployments. Proxmox templates can be imported from the configured node.', 'support-provisioning-portal'); ?></p>
<input </div>
id="spp_memory_quota_mb" </div>
name="spp_memory_quota_mb"
type="number" <h3><?php echo esc_html__('Approved Templates', 'support-provisioning-portal'); ?></h3>
min="0" <div class="spp-template-list">
step="256" <?php if (empty($approved_templates)) : ?>
value="<?php echo esc_attr((string) $quota); ?>" <div class="spp-panel spp-admin-notice">
class="regular-text" <p><?php echo esc_html__('No plugin templates are approved yet. Import one from Proxmox below.', 'support-provisioning-portal'); ?></p>
> </div>
<p class="description"><?php echo esc_html__('Leave empty to use the default per-user limit. Set 0 for unlimited.', 'support-provisioning-portal'); ?></p> <?php endif; ?>
</td> <?php foreach ($approved_templates as $template) : ?>
</tr> <form class="spp-template-row" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
</table> <input type="hidden" name="action" value="spp_save_template">
<input type="hidden" name="spp_template_id" value="<?php echo esc_attr((string) $template['id']); ?>">
<?php wp_nonce_field('spp_save_template'); ?>
<div class="spp-template-row-head">
<div>
<strong><?php echo esc_html((string) $template['name']); ?></strong>
<span><?php echo esc_html(sprintf('PVE VMID %d', (int) $template['proxmoxTemplateId'])); ?></span>
</div>
<?php if (!empty($template['isActive'])) : ?>
<span class="spp-badge RUNNING"><?php echo esc_html__('Active', 'support-provisioning-portal'); ?></span>
<?php else : ?>
<span class="spp-badge STOPPED"><?php echo esc_html__('Inactive', 'support-provisioning-portal'); ?></span>
<?php endif; ?>
</div>
<div class="spp-template-fields">
<label>Name
<input name="spp_template_name" type="text" required maxlength="160" value="<?php echo esc_attr((string) $template['name']); ?>">
</label>
<label>PVE template VMID
<input name="spp_proxmox_template_id" type="number" min="1" required value="<?php echo esc_attr((string) $template['proxmoxTemplateId']); ?>">
</label>
<label>OS type
<?php $this->render_os_type_select((string) $template['osType']); ?>
</label>
<label>CPU cores
<input name="spp_cpu_cores" type="number" min="1" required value="<?php echo esc_attr((string) $template['cpuCores']); ?>">
</label>
<label>Memory MB
<input name="spp_memory_mb" type="number" min="128" step="128" required value="<?php echo esc_attr((string) $template['memoryMb']); ?>">
</label>
<label>Disk GB
<input name="spp_disk_gb" type="number" min="1" required value="<?php echo esc_attr((string) $template['diskGb']); ?>">
</label>
<label>Default TTL hours
<input name="spp_default_ttl_hours" type="number" min="1" max="720" required value="<?php echo esc_attr((string) $template['defaultTtlHours']); ?>">
</label>
<label class="spp-check">
<input name="spp_is_active" type="checkbox" value="1" <?php checked(!empty($template['isActive'])); ?>>
<span>Active for new deployments</span>
</label>
<label class="spp-template-description">Description
<textarea name="spp_template_description" rows="2" required><?php echo esc_textarea((string) $template['description']); ?></textarea>
</label>
</div>
<div class="spp-template-actions">
<button class="button button-primary" name="spp_template_action" value="save" type="submit"><?php echo esc_html__('Save Template', 'support-provisioning-portal'); ?></button>
<button class="button" name="spp_template_action" value="remove" type="submit"><?php echo esc_html__('Remove', 'support-provisioning-portal'); ?></button>
</div>
</form>
<?php endforeach; ?>
</div>
<h3><?php echo esc_html__('Proxmox Templates On Configured Node', 'support-provisioning-portal'); ?></h3>
<?php if ($proxmox_error !== null) : ?>
<p class="spp-error"><?php echo esc_html($proxmox_error); ?></p>
<?php elseif (empty($proxmox_templates)) : ?>
<div class="spp-panel spp-admin-notice">
<p><?php echo esc_html__('No QEMU templates were returned by Proxmox for the configured node.', 'support-provisioning-portal'); ?></p>
</div>
<?php else : ?>
<div class="spp-pve-template-grid">
<?php foreach ($proxmox_templates as $template) : ?>
<?php $is_imported = in_array((int) $template['vmId'], $active_proxmox_ids, true); ?>
<form class="spp-pve-template" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_template">
<input type="hidden" name="spp_template_action" value="import">
<input type="hidden" name="spp_template_name" value="<?php echo esc_attr((string) $template['name']); ?>">
<input type="hidden" name="spp_proxmox_template_id" value="<?php echo esc_attr((string) $template['vmId']); ?>">
<input type="hidden" name="spp_cpu_cores" value="<?php echo esc_attr((string) $template['cpuCores']); ?>">
<input type="hidden" name="spp_memory_mb" value="<?php echo esc_attr((string) $template['memoryMb']); ?>">
<input type="hidden" name="spp_disk_gb" value="<?php echo esc_attr((string) $template['diskGb']); ?>">
<?php wp_nonce_field('spp_save_template'); ?>
<div class="spp-template-row-head">
<div>
<strong><?php echo esc_html((string) $template['name']); ?></strong>
<span><?php echo esc_html(sprintf('PVE VMID %d', (int) $template['vmId'])); ?></span>
</div>
<?php if ($is_imported) : ?>
<span class="spp-badge RUNNING"><?php echo esc_html__('Imported', 'support-provisioning-portal'); ?></span>
<?php endif; ?>
</div>
<div class="spp-meta">
<span>CPU<strong><?php echo esc_html((string) $template['cpuCores']); ?> cores</strong></span>
<span>Memory<strong><?php echo esc_html((string) $template['memoryMb']); ?> MB</strong></span>
<span>Disk<strong><?php echo esc_html((string) $template['diskGb']); ?> GB</strong></span>
<span>Status<strong><?php echo esc_html((string) $template['status']); ?></strong></span>
</div>
<?php if (!$is_imported) : ?>
<label>OS type
<?php $this->render_os_type_select('LINUX'); ?>
</label>
<label>Default TTL hours
<input name="spp_default_ttl_hours" type="number" min="1" max="720" value="168" required>
</label>
<label>Description
<textarea name="spp_template_description" rows="2" required><?php echo esc_textarea(sprintf('Imported from Proxmox template VMID %d.', (int) $template['vmId'])); ?></textarea>
</label>
<button class="button button-primary" type="submit"><?php echo esc_html__('Import Template', 'support-provisioning-portal'); ?></button>
<?php endif; ?>
</form>
<?php endforeach; ?>
</div>
<?php endif; ?>
<h3><?php echo esc_html__('Add Template Manually', 'support-provisioning-portal'); ?></h3>
<form class="spp-template-row" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_template">
<input type="hidden" name="spp_template_action" value="import">
<?php wp_nonce_field('spp_save_template'); ?>
<div class="spp-template-fields">
<label>Name
<input name="spp_template_name" type="text" required maxlength="160">
</label>
<label>PVE template VMID
<input name="spp_proxmox_template_id" type="number" min="1" required>
</label>
<label>OS type
<?php $this->render_os_type_select('LINUX'); ?>
</label>
<label>CPU cores
<input name="spp_cpu_cores" type="number" min="1" value="2" required>
</label>
<label>Memory MB
<input name="spp_memory_mb" type="number" min="128" step="128" value="2048" required>
</label>
<label>Disk GB
<input name="spp_disk_gb" type="number" min="1" value="32" required>
</label>
<label>Default TTL hours
<input name="spp_default_ttl_hours" type="number" min="1" max="720" value="168" required>
</label>
<label class="spp-template-description">Description
<textarea name="spp_template_description" rows="2" required></textarea>
</label>
</div>
<div class="spp-template-actions">
<button class="button button-primary" type="submit"><?php echo esc_html__('Add Template', 'support-provisioning-portal'); ?></button>
</div>
</form>
</section>
<?php <?php
} }
public function save_user_quota_field(int $user_id): void private function render_user_access_management(): void
{ {
if (!current_user_can('edit_user', $user_id)) { $search = isset($_GET['spp_user_search']) ? sanitize_text_field((string) wp_unslash($_GET['spp_user_search'])) : '';
return; $users = $this->users_for_access_table($search);
$definitions = SPP_Permissions::definitions();
?>
<section class="spp-settings spp-user-access">
<div class="spp-section-header">
<div>
<h2><?php echo esc_html__('User Rights', 'support-provisioning-portal'); ?></h2>
<p class="description"><?php echo esc_html__('SSO users appear here after their first WordPress sign-in.', 'support-provisioning-portal'); ?></p>
</div>
<form class="spp-user-search" method="get">
<input type="hidden" name="page" value="support-provisioning-portal">
<input name="spp_user_search" type="search" value="<?php echo esc_attr($search); ?>" placeholder="Search users">
<button class="button" type="submit"><?php echo esc_html__('Search', 'support-provisioning-portal'); ?></button>
</form>
</div>
<?php if (empty($this->permissions->user_ids_with_permission(SPP_Permissions::MANAGE_PERMISSIONS))) : ?>
<p class="spp-warning"><?php echo esc_html__('Assign Manage user rights to at least one user to finish permission bootstrap.', 'support-provisioning-portal'); ?></p>
<?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_user_access">
<?php wp_nonce_field('spp_save_user_access'); ?>
<table class="widefat striped spp-user-access-table">
<thead>
<tr>
<th><?php echo esc_html__('User', 'support-provisioning-portal'); ?></th>
<th><?php echo esc_html__('RAM override', 'support-provisioning-portal'); ?></th>
<th><?php echo esc_html__('Rights', 'support-provisioning-portal'); ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($users)) : ?>
<tr>
<td colspan="3"><?php echo esc_html__('No users found.', 'support-provisioning-portal'); ?></td>
</tr>
<?php endif; ?>
<?php foreach ($users as $user) : ?>
<?php
$user_permissions = $this->permissions->for_user((int) $user->ID);
$quota = get_user_meta((int) $user->ID, 'spp_memory_quota_mb', true);
?>
<tr>
<td>
<input type="hidden" name="spp_user_ids[]" value="<?php echo esc_attr((string) $user->ID); ?>">
<strong><?php echo esc_html($user->display_name !== '' ? $user->display_name : $user->user_login); ?></strong>
<span class="spp-user-login"><?php echo esc_html($user->user_login); ?></span>
<span class="spp-user-login"><?php echo esc_html($user->user_email); ?></span>
</td>
<td>
<input
class="small-text"
name="spp_memory_quota_mb[<?php echo esc_attr((string) $user->ID); ?>]"
type="number"
min="0"
step="256"
value="<?php echo esc_attr((string) $quota); ?>"
placeholder="Default"
>
</td>
<td>
<div class="spp-permission-groups">
<?php foreach (SPP_Permissions::groups() as $group => $permissions) : ?>
<fieldset>
<legend><?php echo esc_html($group); ?></legend>
<?php foreach ($permissions as $permission) : ?>
<label>
<input
type="checkbox"
name="spp_permissions[<?php echo esc_attr((string) $user->ID); ?>][]"
value="<?php echo esc_attr($permission); ?>"
<?php checked(in_array($permission, $user_permissions, true)); ?>
>
<span><?php echo esc_html($definitions[$permission]); ?></span>
</label>
<?php endforeach; ?>
</fieldset>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php submit_button('Save User Rights'); ?>
</form>
</section>
<?php
}
public function save_settings(): void
{
if (!$this->permissions->current_user_has(SPP_Permissions::MANAGE_SETTINGS)) {
wp_die(esc_html__('You do not have permission to save these settings.', 'support-provisioning-portal'));
} }
if (!array_key_exists('spp_memory_quota_mb', $_POST)) { check_admin_referer('spp_save_settings');
return;
update_option('spp_proxmox_mode', $this->posted_string('spp_proxmox_mode') === 'http' ? 'http' : 'mock');
update_option('spp_proxmox_base_url', $this->sanitize_proxmox_base_url($this->posted_string('spp_proxmox_base_url')));
update_option('spp_proxmox_token_id', sanitize_text_field($this->posted_string('spp_proxmox_token_id')));
$token_secret = sanitize_text_field($this->posted_string('spp_proxmox_token_secret'));
if ($token_secret !== '') {
update_option('spp_proxmox_token_secret', $token_secret);
}
update_option('spp_proxmox_node', sanitize_text_field($this->posted_string('spp_proxmox_node')));
update_option('spp_quota_user_memory_mb', max(0, absint($this->posted_string('spp_quota_user_memory_mb'))));
update_option('spp_quota_global_memory_mb', max(0, absint($this->posted_string('spp_quota_global_memory_mb'))));
$this->redirect_to_admin_page('settings_saved');
}
public function save_template(): void
{
if (!$this->permissions->current_user_has(SPP_Permissions::MANAGE_TEMPLATES)) {
wp_die(esc_html__('You do not have permission to save templates.', 'support-provisioning-portal'));
} }
$raw_value = sanitize_text_field(wp_unslash($_POST['spp_memory_quota_mb'])); check_admin_referer('spp_save_template');
if ($raw_value === '') {
delete_user_meta($user_id, 'spp_memory_quota_mb'); $template_id = absint($this->posted_string('spp_template_id'));
return; $action = sanitize_key($this->posted_string('spp_template_action'));
if ($action === 'remove') {
if ($template_id < 1) {
$this->redirect_to_admin_page('template_error');
}
$this->repository->deactivate_template($template_id, get_current_user_id());
$this->redirect_to_admin_page('template_removed');
} }
update_user_meta($user_id, 'spp_memory_quota_mb', max(0, absint($raw_value))); $data = $this->posted_template_data();
if ($data === null) {
$this->redirect_to_admin_page('template_error');
}
if (!$this->proxmox_template_exists((int) $data['proxmox_template_id'])) {
$this->redirect_to_admin_page('template_error');
}
if ($template_id > 0) {
$this->repository->update_template($template_id, $data, get_current_user_id());
} else {
$this->repository->upsert_template($data, get_current_user_id());
}
$this->redirect_to_admin_page('template_saved');
}
public function save_user_access(): void
{
if (!$this->permissions->current_user_has(SPP_Permissions::MANAGE_PERMISSIONS)) {
wp_die(esc_html__('You do not have permission to save user rights.', 'support-provisioning-portal'));
}
check_admin_referer('spp_save_user_access');
$user_ids = $this->posted_user_ids();
$posted_permissions = isset($_POST['spp_permissions']) ? (array) wp_unslash($_POST['spp_permissions']) : [];
$posted_quotas = isset($_POST['spp_memory_quota_mb']) ? (array) wp_unslash($_POST['spp_memory_quota_mb']) : [];
if (!$this->save_keeps_permission_manager($user_ids, $posted_permissions)) {
$this->redirect_to_admin_page('manager_required');
}
foreach ($user_ids as $user_id) {
$permissions = isset($posted_permissions[$user_id]) && is_array($posted_permissions[$user_id])
? SPP_Permissions::sanitize_permissions($posted_permissions[$user_id])
: [];
$quota_raw = isset($posted_quotas[$user_id]) ? sanitize_text_field((string) $posted_quotas[$user_id]) : '';
$memory_quota_mb = $quota_raw === '' ? null : max(0, absint($quota_raw));
$this->repository->update_user_access($user_id, $permissions, $memory_quota_mb, get_current_user_id());
}
$this->redirect_to_admin_page('user_access_saved');
}
/**
* @return array<int, WP_User>
*/
private function users_for_access_table(string $search): array
{
$args = [
'fields' => 'all',
'orderby' => 'display_name',
'order' => 'ASC',
'number' => 200,
];
if ($search !== '') {
$args['search'] = '*' . $search . '*';
$args['search_columns'] = ['user_login', 'user_email', 'user_nicename', 'display_name'];
}
$users = get_users($args);
return is_array($users) ? $users : [];
}
private function render_os_type_select(string $selected): void
{
$options = [
'LINUX' => 'Linux',
'WINDOWS' => 'Windows',
'APPLIANCE' => 'Appliance',
'OTHER' => 'Other',
];
?>
<select name="spp_os_type">
<?php foreach ($options as $value => $label) : ?>
<option value="<?php echo esc_attr($value); ?>" <?php selected($selected, $value); ?>><?php echo esc_html($label); ?></option>
<?php endforeach; ?>
</select>
<?php
}
private function posted_string(string $key): string
{
return isset($_POST[$key]) ? (string) wp_unslash($_POST[$key]) : '';
}
private function sanitize_proxmox_base_url(string $value): string
{
$url = esc_url_raw($value);
if ($url === '') {
return '';
}
return wp_parse_url($url, PHP_URL_SCHEME) === 'https' ? $url : '';
}
private function proxmox_template_exists(int $vm_id): bool
{
try {
foreach ($this->proxmox->list_templates() as $template) {
if ((int) $template['vmId'] === $vm_id) {
return true;
}
}
} catch (Throwable) {
return false;
}
return false;
}
/**
* @return array<string, mixed>|null
*/
private function posted_template_data(): ?array
{
$name = sanitize_text_field($this->posted_string('spp_template_name'));
$proxmox_template_id = absint($this->posted_string('spp_proxmox_template_id'));
$description = sanitize_textarea_field($this->posted_string('spp_template_description'));
if ($name === '' || $proxmox_template_id < 1 || $description === '') {
return null;
}
return [
'template_key' => 'pve-template-' . $proxmox_template_id . '-' . sanitize_title($name),
'name' => $name,
'description' => $description,
'os_type' => $this->posted_os_type(),
'cpu_cores' => max(1, absint($this->posted_string('spp_cpu_cores'))),
'memory_mb' => max(128, absint($this->posted_string('spp_memory_mb'))),
'disk_gb' => max(1, absint($this->posted_string('spp_disk_gb'))),
'default_ttl_hours' => max(1, min(720, absint($this->posted_string('spp_default_ttl_hours')))),
'proxmox_template_id' => $proxmox_template_id,
'is_active' => $this->posted_string('spp_is_active') === '1',
];
}
private function posted_os_type(): string
{
$os_type = strtoupper(sanitize_key($this->posted_string('spp_os_type')));
return in_array($os_type, ['LINUX', 'WINDOWS', 'APPLIANCE', 'OTHER'], true) ? $os_type : 'OTHER';
}
/**
* @return array<int, int>
*/
private function posted_user_ids(): array
{
$raw_user_ids = isset($_POST['spp_user_ids']) ? (array) wp_unslash($_POST['spp_user_ids']) : [];
$user_ids = [];
foreach ($raw_user_ids as $user_id) {
$user_id = absint($user_id);
if ($user_id > 0 && !in_array($user_id, $user_ids, true)) {
$user_ids[] = $user_id;
}
}
return $user_ids;
}
/**
* @param array<int, int> $user_ids
* @param array<mixed> $posted_permissions
*/
private function save_keeps_permission_manager(array $user_ids, array $posted_permissions): bool
{
$updated_user_ids = array_flip($user_ids);
foreach ($this->permissions->user_ids_with_permission(SPP_Permissions::MANAGE_PERMISSIONS) as $manager_id) {
if (!isset($updated_user_ids[$manager_id])) {
return true;
}
}
foreach ($user_ids as $user_id) {
$permissions = isset($posted_permissions[$user_id]) && is_array($posted_permissions[$user_id])
? SPP_Permissions::sanitize_permissions($posted_permissions[$user_id])
: [];
if (in_array(SPP_Permissions::MANAGE_PERMISSIONS, $permissions, true)) {
return true;
}
}
return false;
}
private function redirect_to_admin_page(string $notice): void
{
wp_safe_redirect(add_query_arg([
'page' => 'support-provisioning-portal',
'spp_notice' => $notice,
], admin_url('admin.php')));
exit;
} }
} }

View File

@@ -17,6 +17,35 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
$this->options = $options; $this->options = $options;
} }
public function list_templates(): array
{
$data = $this->request("/nodes/{$this->options['node']}/qemu", 'GET');
$vms = is_array($data) ? $data : [];
$templates = [];
foreach ($vms as $vm) {
if (!is_array($vm) || empty($vm['template']) || empty($vm['vmid'])) {
continue;
}
$templates[] = [
'vmId' => (int) $vm['vmid'],
'name' => isset($vm['name']) && $vm['name'] !== '' ? (string) $vm['name'] : 'template-' . (int) $vm['vmid'],
'cpuCores' => max(1, (int) ($vm['cpus'] ?? 1)),
'memoryMb' => $this->bytes_to_mb((int) ($vm['maxmem'] ?? 0)),
'diskGb' => $this->bytes_to_gb((int) ($vm['maxdisk'] ?? 0)),
'status' => isset($vm['status']) ? (string) $vm['status'] : 'unknown',
];
}
usort(
$templates,
static fn(array $left, array $right): int => strcasecmp((string) $left['name'], (string) $right['name'])
);
return $templates;
}
public function clone_vm(array $input): array public function clone_vm(array $input): array
{ {
$vm_id = (int) $this->request('/cluster/nextid', 'GET'); $vm_id = (int) $this->request('/cluster/nextid', 'GET');
@@ -86,6 +115,24 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
return array_values(array_unique($ips)); return array_values(array_unique($ips));
} }
private function bytes_to_mb(int $bytes): int
{
if ($bytes < 1) {
return 1024;
}
return max(128, (int) ceil($bytes / 1048576));
}
private function bytes_to_gb(int $bytes): int
{
if ($bytes < 1) {
return 8;
}
return max(1, (int) ceil($bytes / 1073741824));
}
/** /**
* @param array<string, int|string> $body * @param array<string, int|string> $body
* @return mixed * @return mixed

View File

@@ -6,6 +6,36 @@ if (!defined('ABSPATH')) {
final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
{ {
public function list_templates(): array
{
return [
[
'vmId' => 9001,
'name' => 'Turnkey PBX Test Appliance',
'cpuCores' => 2,
'memoryMb' => 2048,
'diskGb' => 24,
'status' => 'stopped',
],
[
'vmId' => 9002,
'name' => 'Windows Support Client',
'cpuCores' => 4,
'memoryMb' => 8192,
'diskGb' => 80,
'status' => 'stopped',
],
[
'vmId' => 9003,
'name' => 'Linux Utility VM',
'cpuCores' => 2,
'memoryMb' => 2048,
'diskGb' => 32,
'status' => 'stopped',
],
];
}
public function clone_vm(array $input): array public function clone_vm(array $input): array
{ {
$next_id = (int) get_option('spp_mock_next_vm_id', 10000); $next_id = (int) get_option('spp_mock_next_vm_id', 10000);

View File

@@ -0,0 +1,181 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
final class SPP_Permissions
{
public const META_KEY = 'spp_permissions';
public const VIEW_PORTAL = 'view_portal';
public const CREATE_DEPLOYMENTS = 'create_deployments';
public const START_DEPLOYMENTS = 'start_deployments';
public const STOP_DEPLOYMENTS = 'stop_deployments';
public const PROLONG_DEPLOYMENTS = 'prolong_deployments';
public const REFRESH_DEPLOYMENT_IPS = 'refresh_deployment_ips';
public const DELETE_DEPLOYMENTS = 'delete_deployments';
public const MANAGE_ALL_DEPLOYMENTS = 'manage_all_deployments';
public const MANAGE_TEMPLATES = 'manage_templates';
public const MANAGE_SETTINGS = 'manage_settings';
public const MANAGE_PERMISSIONS = 'manage_permissions';
/**
* @return array<string, string>
*/
public static function definitions(): array
{
return [
self::VIEW_PORTAL => 'Open portal and view deployments',
self::CREATE_DEPLOYMENTS => 'Create deployments',
self::START_DEPLOYMENTS => 'Start deployments',
self::STOP_DEPLOYMENTS => 'Stop deployments',
self::PROLONG_DEPLOYMENTS => 'Prolong deployments',
self::REFRESH_DEPLOYMENT_IPS => 'Refresh IP addresses',
self::DELETE_DEPLOYMENTS => 'Delete deployments',
self::MANAGE_ALL_DEPLOYMENTS => 'View and manage all deployments',
self::MANAGE_TEMPLATES => 'Manage templates',
self::MANAGE_SETTINGS => 'Manage Proxmox settings and quotas',
self::MANAGE_PERMISSIONS => 'Manage user rights',
];
}
/**
* @return array<string, array<int, string>>
*/
public static function groups(): array
{
return [
'Portal' => [
self::VIEW_PORTAL,
self::CREATE_DEPLOYMENTS,
],
'Lifecycle' => [
self::START_DEPLOYMENTS,
self::STOP_DEPLOYMENTS,
self::PROLONG_DEPLOYMENTS,
self::REFRESH_DEPLOYMENT_IPS,
self::DELETE_DEPLOYMENTS,
],
'Administration' => [
self::MANAGE_ALL_DEPLOYMENTS,
self::MANAGE_TEMPLATES,
self::MANAGE_SETTINGS,
self::MANAGE_PERMISSIONS,
],
];
}
/**
* @param array<mixed> $permissions
* @return array<int, string>
*/
public static function sanitize_permissions(array $permissions): array
{
$valid = array_keys(self::definitions());
$selected = [];
foreach ($permissions as $permission) {
$permission = sanitize_key((string) $permission);
if (in_array($permission, $valid, true) && !in_array($permission, $selected, true)) {
$selected[] = $permission;
}
}
$portal_rights = array_diff($selected, [
self::VIEW_PORTAL,
self::MANAGE_TEMPLATES,
self::MANAGE_SETTINGS,
self::MANAGE_PERMISSIONS,
]);
if (!empty($portal_rights) && !in_array(self::VIEW_PORTAL, $selected, true)) {
$selected[] = self::VIEW_PORTAL;
}
return array_values(array_intersect($valid, $selected));
}
public function current_user_has(string $permission): bool
{
if (!array_key_exists($permission, self::definitions())) {
return false;
}
if ($this->user_has(get_current_user_id(), $permission)) {
return true;
}
return $this->has_bootstrap_access();
}
public function current_user_has_any(): bool
{
return !empty($this->current_user_permissions());
}
/**
* @return array<int, string>
*/
public function current_user_permissions(): array
{
if ($this->has_bootstrap_access()) {
return array_keys(self::definitions());
}
return $this->for_user(get_current_user_id());
}
public function user_has(int $user_id, string $permission): bool
{
if (!array_key_exists($permission, self::definitions())) {
return false;
}
return in_array($permission, $this->for_user($user_id), true);
}
/**
* @return array<int, string>
*/
public function for_user(int $user_id): array
{
if ($user_id < 1) {
return [];
}
$permissions = get_user_meta($user_id, self::META_KEY, true);
return is_array($permissions) ? self::sanitize_permissions($permissions) : [];
}
/**
* @return array<int, int>
*/
public function user_ids_with_permission(string $permission): array
{
if (!array_key_exists($permission, self::definitions())) {
return [];
}
global $wpdb;
$like = '%"' . $wpdb->esc_like($permission) . '"%';
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value LIKE %s",
self::META_KEY,
$like
)
);
return array_values(array_map('intval', is_array($ids) ? $ids : []));
}
private function has_bootstrap_access(): bool
{
return is_user_logged_in()
&& current_user_can('manage_options')
&& empty($this->user_ids_with_permission(self::MANAGE_PERMISSIONS));
}
}

View File

@@ -22,14 +22,15 @@ final class SPP_Plugin
SPP_Activator::maybe_upgrade(); SPP_Activator::maybe_upgrade();
$repository = new SPP_Repository(); $repository = new SPP_Repository();
$permissions = new SPP_Permissions();
$proxmox = $this->make_proxmox_client(); $proxmox = $this->make_proxmox_client();
$expiration_service = new SPP_Expiration_Service($repository, $proxmox); $expiration_service = new SPP_Expiration_Service($repository, $proxmox);
add_action('spp_expire_deployments', [$expiration_service, 'expire_due_deployments']); add_action('spp_expire_deployments', [$expiration_service, 'expire_due_deployments']);
(new SPP_REST_Controller($repository, $proxmox, $expiration_service))->register_hooks(); (new SPP_REST_Controller($repository, $proxmox, $expiration_service, $permissions))->register_hooks();
(new SPP_Admin_Page())->register_hooks(); (new SPP_Admin_Page($repository, $permissions, $proxmox))->register_hooks();
(new SPP_Shortcode())->register_hooks(); (new SPP_Shortcode($permissions))->register_hooks();
} }
private function make_proxmox_client(): SPP_Proxmox_Client private function make_proxmox_client(): SPP_Proxmox_Client

View File

@@ -19,6 +19,19 @@ final class SPP_Repository
return array_map([$this, 'template_dto'], is_array($rows) ? $rows : []); return array_map([$this, 'template_dto'], is_array($rows) ? $rows : []);
} }
/**
* @return array<int, array<string, mixed>>
*/
public function admin_templates(): array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY is_active DESC, name ASC", ARRAY_A);
return array_map([$this, 'template_dto'], is_array($rows) ? $rows : []);
}
/** /**
* @return array<string, mixed>|null * @return array<string, mixed>|null
*/ */
@@ -35,23 +48,35 @@ final class SPP_Repository
/** /**
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
public function deployments(): array public function deployments(int $actor_id, bool $include_all = false): array
{ {
global $wpdb; global $wpdb;
$deployments = SPP_Activator::table('deployments'); $deployments = SPP_Activator::table('deployments');
$templates = SPP_Activator::table('templates'); $templates = SPP_Activator::table('templates');
$shares = SPP_Activator::table('deployment_shares');
$users = $wpdb->users; $users = $wpdb->users;
$rows = $wpdb->get_results( $sql = "SELECT DISTINCT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name,
"SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name CASE
WHEN d.requested_by = %d THEN 'owner'
WHEN s.user_id IS NOT NULL THEN 'shared'
ELSE 'admin'
END AS access_type
FROM {$deployments} d FROM {$deployments} d
INNER JOIN {$templates} t ON t.id = d.template_id INNER JOIN {$templates} t ON t.id = d.template_id
INNER JOIN {$users} u ON u.ID = d.requested_by INNER JOIN {$users} u ON u.ID = d.requested_by
WHERE d.status <> 'DELETED' LEFT JOIN {$shares} s ON s.deployment_id = d.id AND s.user_id = %d
ORDER BY d.created_at DESC", WHERE d.status <> 'DELETED'";
ARRAY_A $params = [$actor_id, $actor_id];
);
if (!$include_all) {
$sql .= ' AND (d.requested_by = %d OR s.user_id IS NOT NULL)';
$params[] = $actor_id;
}
$sql .= ' ORDER BY d.created_at DESC';
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return array_map([$this, 'deployment_summary_dto'], is_array($rows) ? $rows : []); return array_map([$this, 'deployment_summary_dto'], is_array($rows) ? $rows : []);
} }
@@ -82,6 +107,41 @@ final class SPP_Repository
return is_array($row) ? $this->deployment_detail_dto($row) : null; return is_array($row) ? $this->deployment_detail_dto($row) : null;
} }
/**
* @return array<string, mixed>|null
*/
public function deployment_for_user(int $id, int $actor_id, bool $include_all = false): ?array
{
global $wpdb;
$deployments = SPP_Activator::table('deployments');
$templates = SPP_Activator::table('templates');
$shares = SPP_Activator::table('deployment_shares');
$users = $wpdb->users;
$sql = "SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name,
CASE
WHEN d.requested_by = %d THEN 'owner'
WHEN s.user_id IS NOT NULL THEN 'shared'
ELSE 'admin'
END AS access_type
FROM {$deployments} d
INNER JOIN {$templates} t ON t.id = d.template_id
INNER JOIN {$users} u ON u.ID = d.requested_by
LEFT JOIN {$shares} s ON s.deployment_id = d.id AND s.user_id = %d
WHERE d.id = %d AND d.status <> 'DELETED'";
$params = [$actor_id, $actor_id, $id];
if (!$include_all) {
$sql .= ' AND (d.requested_by = %d OR s.user_id IS NOT NULL)';
$params[] = $actor_id;
}
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
return is_array($row) ? $this->deployment_detail_dto($row) : null;
}
/** /**
* @param array<string, mixed> $template * @param array<string, mixed> $template
* @return array<string, mixed> * @return array<string, mixed>
@@ -128,6 +188,10 @@ final class SPP_Repository
'updated_at' => current_time('mysql'), 'updated_at' => current_time('mysql'),
], ['id' => $id]); ], ['id' => $id]);
if ($status === 'DELETED') {
$wpdb->delete(SPP_Activator::table('deployment_shares'), ['deployment_id' => $id]);
}
$this->audit($action, 'deployment', $id, $actor_id, ['status' => $status]); $this->audit($action, 'deployment', $id, $actor_id, ['status' => $status]);
return $this->deployment($id); return $this->deployment($id);
@@ -204,6 +268,115 @@ final class SPP_Repository
return is_array($row) ? $row : null; return is_array($row) ? $row : null;
} }
public function user_can_access_deployment(int $deployment_id, int $actor_id, bool $include_all = false): bool
{
if ($include_all) {
return $this->deployment_record($deployment_id) !== null;
}
return $this->user_owns_deployment($deployment_id, $actor_id)
|| $this->deployment_is_shared_with_user($deployment_id, $actor_id);
}
public function user_owns_deployment(int $deployment_id, int $actor_id): bool
{
global $wpdb;
$table = SPP_Activator::table('deployments');
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE id = %d AND requested_by = %d",
$deployment_id,
$actor_id
)
) > 0;
}
private function deployment_is_shared_with_user(int $deployment_id, int $actor_id): bool
{
global $wpdb;
$table = SPP_Activator::table('deployment_shares');
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE deployment_id = %d AND user_id = %d",
$deployment_id,
$actor_id
)
) > 0;
}
/**
* @return array<int, array{id:int,displayName:string,userLogin:string,userEmail:string,sharedAt:string}>
*/
public function deployment_shares(int $deployment_id): array
{
global $wpdb;
$shares = SPP_Activator::table('deployment_shares');
$users = $wpdb->users;
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT u.ID AS id, u.display_name, u.user_login, u.user_email, s.created_at
FROM {$shares} s
INNER JOIN {$users} u ON u.ID = s.user_id
WHERE s.deployment_id = %d
ORDER BY u.display_name ASC, u.user_login ASC",
$deployment_id
),
ARRAY_A
);
return array_map(static function (array $row): array {
return [
'id' => (int) $row['id'],
'displayName' => (string) $row['display_name'],
'userLogin' => (string) $row['user_login'],
'userEmail' => (string) $row['user_email'],
'sharedAt' => (string) $row['created_at'],
];
}, is_array($rows) ? $rows : []);
}
public function share_deployment(int $deployment_id, int $target_user_id, int $actor_id): void
{
global $wpdb;
$record = $this->deployment_record($deployment_id);
if ($record === null || (int) $record['requested_by'] === $target_user_id) {
return;
}
$table = SPP_Activator::table('deployment_shares');
$wpdb->replace($table, [
'deployment_id' => $deployment_id,
'user_id' => $target_user_id,
'created_by' => $actor_id,
'created_at' => current_time('mysql'),
]);
$this->audit('DEPLOYMENT_SHARED', 'deployment', $deployment_id, $actor_id, [
'shared_with_user_id' => $target_user_id,
]);
}
public function unshare_deployment(int $deployment_id, int $target_user_id, int $actor_id): void
{
global $wpdb;
$table = SPP_Activator::table('deployment_shares');
$wpdb->delete($table, [
'deployment_id' => $deployment_id,
'user_id' => $target_user_id,
]);
$this->audit('DEPLOYMENT_UNSHARED', 'deployment', $deployment_id, $actor_id, [
'unshared_user_id' => $target_user_id,
]);
}
/** /**
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -257,6 +430,146 @@ final class SPP_Repository
]; ];
} }
/**
* @param array<string, mixed> $data
*/
public function upsert_template(array $data, int $actor_id): ?array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$now = current_time('mysql');
$template_key = $this->unique_template_key((string) $data['template_key'], (int) $data['proxmox_template_id']);
$existing_id = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE proxmox_template_id = %d OR template_key = %s ORDER BY proxmox_template_id = %d DESC LIMIT 1",
(int) $data['proxmox_template_id'],
$template_key,
(int) $data['proxmox_template_id']
)
);
$row = [
'template_key' => $template_key,
'name' => sanitize_text_field((string) $data['name']),
'description' => sanitize_textarea_field((string) $data['description']),
'os_type' => strtoupper(sanitize_key((string) $data['os_type'])),
'cpu_cores' => max(1, absint($data['cpu_cores'])),
'memory_mb' => max(128, absint($data['memory_mb'])),
'disk_gb' => max(1, absint($data['disk_gb'])),
'default_ttl_hours' => max(1, min(720, absint($data['default_ttl_hours']))),
'proxmox_template_id' => absint($data['proxmox_template_id']),
'is_active' => 1,
'updated_at' => $now,
];
if ($existing_id > 0) {
$wpdb->update($table, $row, ['id' => $existing_id]);
$template_id = $existing_id;
$action = 'TEMPLATE_UPDATED';
} else {
$wpdb->insert($table, array_merge($row, ['created_at' => $now]));
$template_id = (int) $wpdb->insert_id;
$action = 'TEMPLATE_IMPORTED';
}
$this->audit($action, 'template', $template_id, $actor_id, [
'proxmox_template_id' => (int) $row['proxmox_template_id'],
'name' => (string) $row['name'],
]);
return $this->template_for_admin($template_id);
}
/**
* @param array<string, mixed> $data
*/
public function update_template(int $id, array $data, int $actor_id): ?array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$row = [
'name' => sanitize_text_field((string) $data['name']),
'description' => sanitize_textarea_field((string) $data['description']),
'os_type' => strtoupper(sanitize_key((string) $data['os_type'])),
'cpu_cores' => max(1, absint($data['cpu_cores'])),
'memory_mb' => max(128, absint($data['memory_mb'])),
'disk_gb' => max(1, absint($data['disk_gb'])),
'default_ttl_hours' => max(1, min(720, absint($data['default_ttl_hours']))),
'proxmox_template_id' => absint($data['proxmox_template_id']),
'is_active' => empty($data['is_active']) ? 0 : 1,
'updated_at' => current_time('mysql'),
];
$wpdb->update($table, $row, ['id' => $id]);
$this->audit('TEMPLATE_UPDATED', 'template', $id, $actor_id, [
'proxmox_template_id' => (int) $row['proxmox_template_id'],
'name' => (string) $row['name'],
'is_active' => (int) $row['is_active'],
]);
return $this->template_for_admin($id);
}
public function deactivate_template(int $id, int $actor_id): ?array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$wpdb->update($table, [
'is_active' => 0,
'updated_at' => current_time('mysql'),
], ['id' => $id]);
$this->audit('TEMPLATE_REMOVED', 'template', $id, $actor_id, []);
return $this->template_for_admin($id);
}
/**
* @return array<int, int>
*/
public function active_proxmox_template_ids(): array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$ids = $wpdb->get_col("SELECT proxmox_template_id FROM {$table} WHERE is_active = 1");
return array_values(array_map('intval', is_array($ids) ? $ids : []));
}
/**
* @param array<int, string> $permissions
*/
public function update_user_access(int $user_id, array $permissions, ?int $memory_quota_mb, int $actor_id): void
{
if ($user_id < 1) {
return;
}
$permissions = SPP_Permissions::sanitize_permissions($permissions);
if (empty($permissions)) {
delete_user_meta($user_id, SPP_Permissions::META_KEY);
} else {
update_user_meta($user_id, SPP_Permissions::META_KEY, $permissions);
}
if ($memory_quota_mb === null) {
delete_user_meta($user_id, 'spp_memory_quota_mb');
} else {
update_user_meta($user_id, 'spp_memory_quota_mb', max(0, $memory_quota_mb));
}
$this->audit('USER_ACCESS_UPDATED', 'user', $user_id, $actor_id, [
'permissions' => $permissions,
'memory_quota_mb' => $memory_quota_mb,
]);
}
private function user_memory_limit_mb(int $actor_id): int private function user_memory_limit_mb(int $actor_id): int
{ {
$user_limit = get_user_meta($actor_id, 'spp_memory_quota_mb', true); $user_limit = get_user_meta($actor_id, 'spp_memory_quota_mb', true);
@@ -291,6 +604,30 @@ final class SPP_Repository
return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
} }
/**
* @return array<string, mixed>|null
*/
private function template_for_admin(int $id): ?array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A);
return is_array($row) ? $this->template_dto($row) : null;
}
private function unique_template_key(string $raw_key, int $proxmox_template_id): string
{
$key = sanitize_title($raw_key);
if ($key === '') {
$key = 'pve-template-' . $proxmox_template_id;
}
return substr($key, 0, 80);
}
/** /**
* @param array<string, mixed> $metadata * @param array<string, mixed> $metadata
*/ */
@@ -324,6 +661,8 @@ final class SPP_Repository
'memoryMb' => (int) $row['memory_mb'], 'memoryMb' => (int) $row['memory_mb'],
'diskGb' => (int) $row['disk_gb'], 'diskGb' => (int) $row['disk_gb'],
'defaultTtlHours' => (int) $row['default_ttl_hours'], 'defaultTtlHours' => (int) $row['default_ttl_hours'],
'proxmoxTemplateId' => (int) $row['proxmox_template_id'],
'isActive' => (int) $row['is_active'] === 1,
]; ];
} }
@@ -338,7 +677,9 @@ final class SPP_Repository
'name' => (string) $row['name'], 'name' => (string) $row['name'],
'status' => (string) $row['status'], 'status' => (string) $row['status'],
'templateName' => (string) $row['template_name'], 'templateName' => (string) $row['template_name'],
'requestedById' => (int) $row['requested_by'],
'requestedByName' => (string) $row['requested_by_name'], 'requestedByName' => (string) $row['requested_by_name'],
'accessType' => isset($row['access_type']) ? (string) $row['access_type'] : null,
'ipAddresses' => $this->ip_addresses_from_row($row), 'ipAddresses' => $this->ip_addresses_from_row($row),
'expiresAt' => $row['expires_at'] === null ? null : (string) $row['expires_at'], 'expiresAt' => $row['expires_at'] === null ? null : (string) $row['expires_at'],
'createdAt' => (string) $row['created_at'], 'createdAt' => (string) $row['created_at'],

View File

@@ -11,7 +11,8 @@ final class SPP_REST_Controller
public function __construct( public function __construct(
private SPP_Repository $repository, private SPP_Repository $repository,
private SPP_Proxmox_Client $proxmox, 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, 'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_deployment'], '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, 'methods' => WP_REST_Server::DELETABLE,
'callback' => [$this, 'delete_deployment'], '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', [ register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/start', [
'methods' => WP_REST_Server::CREATABLE, 'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'start_deployment'], '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', [ register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/stop', [
'methods' => WP_REST_Server::CREATABLE, 'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'stop_deployment'], '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', [ register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/prolong', [
'methods' => WP_REST_Server::CREATABLE, 'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'prolong_deployment'], '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', [ register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/refresh-ips', [
'methods' => WP_REST_Server::CREATABLE, 'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'refresh_deployment_ips'], 'callback' => [$this, 'refresh_deployment_ips'],
'permission_callback' => [$this, 'can_mutate'], 'permission_callback' => [$this, 'can_refresh_deployment_ips'],
]); ]);
} }
public function can_read(): bool 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 public function list_templates(): WP_REST_Response
@@ -113,19 +168,26 @@ final class SPP_REST_Controller
{ {
$this->sync_expirations(); $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 public function get_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
{ {
$this->sync_expirations(); $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) { if ($deployment === null) {
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); 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 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_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 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]); 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'); $ttl_hours = (int) $request->get_param('ttlHours');
$never_expire = (bool) $request->get_param('neverExpire'); $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) { if ($deployment === null) {
return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); 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') { 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) { if ($quota_error instanceof WP_Error) {
return $quota_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 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]); 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'])) { if (empty($record['proxmox_vm_id'])) {
return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]); return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]);
} }
try { try {
$deployment = $this->repository->update_deployment_ips( $this->repository->update_deployment_ips(
$id, $id,
$this->proxmox->get_ip_addresses((int) $record['proxmox_vm_id']), $this->proxmox->get_ip_addresses((int) $record['proxmox_vm_id']),
get_current_user_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 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 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]); 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') { if ($method === 'start_vm' && $record['status'] === 'EXPIRED') {
return new WP_Error( return new WP_Error(
'spp_expired_deployment', 'spp_expired_deployment',
@@ -312,7 +461,7 @@ final class SPP_REST_Controller
try { try {
$this->proxmox->{$method}((int) $record['proxmox_vm_id']); $this->proxmox->{$method}((int) $record['proxmox_vm_id']);
if ($method === 'start_vm') { if ($method === 'start_vm') {
$deployment = $this->repository->update_deployment_status_and_ips( $this->repository->update_deployment_status_and_ips(
$id, $id,
$status, $status,
$this->safe_ip_addresses((int) $record['proxmox_vm_id']), $this->safe_ip_addresses((int) $record['proxmox_vm_id']),
@@ -320,13 +469,80 @@ final class SPP_REST_Controller
get_current_user_id() get_current_user_id()
); );
} else { } 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) { } catch (Throwable $error) {
return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]); 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 private function sync_expirations(): void

View File

@@ -6,6 +6,10 @@ if (!defined('ABSPATH')) {
final class SPP_Shortcode final class SPP_Shortcode
{ {
public function __construct(private SPP_Permissions $permissions)
{
}
public function register_hooks(): void public function register_hooks(): void
{ {
add_shortcode('support_provisioning_portal', [$this, 'render']); add_shortcode('support_provisioning_portal', [$this, 'render']);
@@ -20,13 +24,18 @@ final class SPP_Shortcode
return '<p class="spp-login-required">Please sign in to access the provisioning portal.</p>'; return '<p class="spp-login-required">Please sign in to access the provisioning portal.</p>';
} }
if (!$this->permissions->current_user_has(SPP_Permissions::VIEW_PORTAL)) {
return '<p class="spp-login-required">You do not have permission to access the provisioning portal.</p>';
}
wp_enqueue_style('spp-portal', SPP_PLUGIN_URL . 'assets/portal.css', [], SPP_VERSION); wp_enqueue_style('spp-portal', SPP_PLUGIN_URL . 'assets/portal.css', [], SPP_VERSION);
wp_enqueue_script('spp-portal', SPP_PLUGIN_URL . 'assets/portal.js', [], SPP_VERSION, true); wp_enqueue_script('spp-portal', SPP_PLUGIN_URL . 'assets/portal.js', [], SPP_VERSION, true);
return sprintf( return sprintf(
'<div id="spp-portal-root" class="spp-portal" data-rest-url="%s" data-nonce="%s"></div>', '<div id="spp-portal-root" class="spp-portal" data-rest-url="%s" data-nonce="%s" data-permissions="%s"></div>',
esc_url_raw(rest_url('support-provisioning/v1')), esc_url_raw(rest_url('support-provisioning/v1')),
esc_attr(wp_create_nonce('wp_rest')) esc_attr(wp_create_nonce('wp_rest')),
esc_attr((string) wp_json_encode($this->permissions->current_user_permissions()))
); );
} }
} }

View File

@@ -6,6 +6,11 @@ if (!defined('ABSPATH')) {
interface SPP_Proxmox_Client interface SPP_Proxmox_Client
{ {
/**
* @return array<int, array{vmId:int,name:string,cpuCores:int,memoryMb:int,diskGb:int,status:string}>
*/
public function list_templates(): array;
/** /**
* @param array<string, int|string> $input * @param array<string, int|string> $input
* @return array{vm_id:int} * @return array{vm_id:int}

View File

@@ -2,7 +2,7 @@
/** /**
* Plugin Name: Support Provisioning Portal * Plugin Name: Support Provisioning Portal
* Description: Internal self-service portal for provisioning standardized Proxmox VE VMs. * Description: Internal self-service portal for provisioning standardized Proxmox VE VMs.
* Version: 0.3.0 * Version: 0.6.0
* Author: Internal Support * Author: Internal Support
* Requires PHP: 8.0 * Requires PHP: 8.0
* Requires at least: 6.2 * Requires at least: 6.2
@@ -13,11 +13,12 @@ if (!defined('ABSPATH')) {
exit; exit;
} }
define('SPP_VERSION', '0.3.0'); define('SPP_VERSION', '0.6.0');
define('SPP_PLUGIN_FILE', __FILE__); define('SPP_PLUGIN_FILE', __FILE__);
define('SPP_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('SPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('SPP_PLUGIN_URL', plugin_dir_url(__FILE__)); define('SPP_PLUGIN_URL', plugin_dir_url(__FILE__));
require_once SPP_PLUGIN_DIR . 'includes/class-spp-permissions.php';
require_once SPP_PLUGIN_DIR . 'includes/class-spp-activator.php'; require_once SPP_PLUGIN_DIR . 'includes/class-spp-activator.php';
require_once SPP_PLUGIN_DIR . 'includes/class-spp-repository.php'; require_once SPP_PLUGIN_DIR . 'includes/class-spp-repository.php';
require_once SPP_PLUGIN_DIR . 'includes/interface-spp-proxmox-client.php'; require_once SPP_PLUGIN_DIR . 'includes/interface-spp-proxmox-client.php';