diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7d3caba --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 2ec8bb9..b6e3e73 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The application runs as a WordPress plugin and exposes both: - Custom database tables: - `wp_spp_templates` - `wp_spp_deployments` + - `wp_spp_deployment_shares` - `wp_spp_audit_logs` - Seed data for 3 approved templates - 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/prolong` - `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` - Mock Proxmox adapter for local/no-cluster use - 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 - Manual IP refresh action for deployments - 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: - deployment dashboard - templates @@ -63,10 +71,36 @@ The application runs as a WordPress plugin and exposes both: ## Permissions -- Logged-in users with `read` can view templates and deployments. -- Logged-in users with `edit_posts` can create, start, stop, and delete deployments. +- The plugin uses its own per-user rights stored in user metadata, not WordPress roles. +- 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. +## 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 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. - 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) - Global RAM limit (MB) 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 @@ -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: - 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 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` 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 -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 | | --- | ---: | @@ -123,26 +166,16 @@ Seeded template IDs: | Windows Support Client | `9002` | | 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: - The VM must be converted to a Proxmox template. - 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. -- 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. -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 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: - getting the next VMID +- listing QEMU templates on the configured node - cloning a template VM - changing CPU and memory after clone - 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 -In WordPress admin, open **Support Provisioning > Proxmox Settings**: +In WordPress admin, open **Support Provisioning** and use the **Proxmox Settings** section: | Setting | Value | | --- | --- | @@ -279,7 +313,7 @@ Then test in this order: - Resource limits come from approved templates, not user input. - 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. -- 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 diff --git a/support-provisioning-portal/assets/portal.css b/support-provisioning-portal/assets/portal.css index 66599a3..3d42d7c 100644 --- a/support-provisioning-portal/assets/portal.css +++ b/support-provisioning-portal/assets/portal.css @@ -16,6 +16,11 @@ align-items: start; } +.spp-admin-stack { + display: grid; + gap: 20px; +} + .spp-settings, .spp-panel, .spp-card { @@ -28,6 +33,14 @@ padding: 18px; } +.spp-admin-notice { + padding: 16px; +} + +.spp-admin-notice p { + margin: 0; +} + .spp-settings label { display: grid; gap: 6px; @@ -37,6 +50,7 @@ .spp-settings input, .spp-settings select, +.spp-settings textarea, .spp-input, .spp-select { min-height: 38px; @@ -48,6 +62,10 @@ padding: 7px 10px; } +.spp-settings textarea { + min-height: 72px; +} + .spp-header { display: flex; flex-wrap: wrap; @@ -166,6 +184,21 @@ 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-error { background: #fee2e2; @@ -242,8 +275,51 @@ 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-prolong-form label { +.spp-prolong-form label, +.spp-share-form label { display: grid; gap: 6px; font-weight: 700; @@ -264,9 +340,148 @@ 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) { .spp-admin-grid, - .spp-grid { + .spp-grid, + .spp-permission-groups, + .spp-pve-template-grid, + .spp-template-fields, + .spp-share-form { grid-template-columns: 1fr; } } diff --git a/support-provisioning-portal/assets/portal.js b/support-provisioning-portal/assets/portal.js index d433ce9..9b2725a 100644 --- a/support-provisioning-portal/assets/portal.js +++ b/support-provisioning-portal/assets/portal.js @@ -4,6 +4,8 @@ roots.forEach((root) => { const api = root.dataset.restUrl; const nonce = root.dataset.nonce; + const permissions = new Set(JSON.parse(root.dataset.permissions || "[]")); + const can = (permission) => permissions.has(permission); let state = { view: "deployments", deployments: [], @@ -102,8 +104,58 @@ 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) => `${status.replace("_", " ")}`; + const accessLabel = (deployment) => { + if (deployment.accessType === "shared") { + return 'SHARED'; + } + + if (deployment.accessType === "admin") { + return 'ADMIN'; + } + + return 'OWNER'; + }; + + const actionButton = (permission, action, label, id, className = "", disabled = false) => { + if (!can(permission)) { + return ""; + } + + return ``; + }; + const ipList = (ips) => { if (!Array.isArray(ips) || ips.length === 0) { return "Pending"; @@ -149,7 +201,7 @@
| Name | Status | Template | IP addresses | Expires | ||
|---|---|---|---|---|---|---|
| Name | Status | Access | Template | IP addresses | Expires | |
| ${escapeHtml(deployment.name)} | ${statusBadge(deployment.status)} | +${accessLabel(deployment)} | ${escapeHtml(deployment.templateName)} | ${ipList(deployment.ipAddresses)} | ${dateTime(deployment.expiresAt)} | @@ -200,28 +253,34 @@ `; - const createView = () => ` -