diff --git a/create-a-container/client/src/components/containers/TransferOwnership.tsx b/create-a-container/client/src/components/containers/TransferOwnership.tsx new file mode 100644 index 00000000..b6802ae9 --- /dev/null +++ b/create-a-container/client/src/components/containers/TransferOwnership.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Button, + Input, + Modal, + ModalBody, + ModalClose, + ModalFooter, + ModalHeader, + ModalTitle, + useToast, +} from '@mieweb/ui'; +import { UserRoundCog } from 'lucide-react'; +import { ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; + +/** Admin-only control to transfer a container to another user (server-enforced). */ +export function TransferOwnership({ + siteId, + containerId, + hostname, + owner, +}: { + siteId: string; + containerId: number; + hostname: string; + owner: string; +}) { + const qc = useQueryClient(); + const toast = useToast(); + const [value, setValue] = useState(''); + const [confirmOpen, setConfirmOpen] = useState(false); + const [error, setError] = useState(null); + const newOwner = value.trim(); + + const transfer = useMutation({ + mutationFn: () => queries.transferContainerOwnership(siteId, containerId, newOwner), + onSuccess: (result) => { + setConfirmOpen(false); + setError(null); + setValue(''); + toast.success(result.message); + qc.invalidateQueries({ queryKey: keys.containers(siteId) }); + qc.invalidateQueries({ queryKey: keys.container(siteId, containerId) }); + }, + onError: (err: ApiError) => { + setConfirmOpen(false); + setError(err.message); + }, + }); + + const canSubmit = !!newOwner && newOwner !== owner; + + return ( +
+

+ Current owner: {owner} +

+
+
+ { + setValue(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + // Keep Enter from submitting the enclosing container form. + if (e.key === 'Enter') { + e.preventDefault(); + if (canSubmit) setConfirmOpen(true); + } + }} + /> +
+ +
+ + + + Transfer ownership? + + + +

+ {hostname} will be owned by {newOwner}. Its approved + resources and volume data move with it. {owner} will lose access unless the new owner + shares it with them. +

+
+ + + + +
+
+ ); +} diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts index 1a0ad3a0..da4d006e 100644 --- a/create-a-container/client/src/lib/queries.ts +++ b/create-a-container/client/src/lib/queries.ts @@ -114,6 +114,11 @@ export const queries = { api.delete<{ collaborators: string[] }>( `/api/v1/sites/${siteId}/containers/${id}/collaborators/${encodeURIComponent(username)}`, ), + transferContainerOwnership: (siteId: number | string, id: number | string, username: string) => + api.put<{ containerId: number; message: string }>( + `/api/v1/sites/${siteId}/containers/${id}`, + { username }, + ), containerBootstrap: (siteId: number | string) => api.get(`/api/v1/sites/${siteId}/containers/new`), containerMetadata: (siteId: number | string, image: string) => diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 32fe76be..6959cdd8 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -34,6 +34,7 @@ import { FormPageHeader } from '@/components/FormPageHeader'; import { randomHostname } from '@/lib/randomHostname'; import { ResourcesSection } from '@/components/containers/ResourcesSection'; import { AddCollaboratorField, CollaboratorChips, CollaboratorsManager } from '@/components/containers/CollaboratorsManager'; +import { TransferOwnership } from '@/components/containers/TransferOwnership'; import type { ContainerCreateResult, ContainerMetadata } from '@/lib/types'; function useDebouncedValue(value: T, delay = 500): T { @@ -983,6 +984,21 @@ export function ContainerFormPage() { })} + {isEdit && container && session?.isAdmin && ( + + + Ownership + + + + + + )} Sharing diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 81f6e935..076bc17e 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -923,7 +923,10 @@ paths: properties: username: type: string - description: '(Admin only) Reassign ownership of this container to the specified existing user' + description: >- + (Admin only) Transfer ownership to another active user. The + new owner's sharing grant is removed and the container's + resource requests move with it. services: type: object description: >- @@ -963,7 +966,8 @@ paths: message: { type: string } '400': { $ref: '#/components/responses/BadRequest' } '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view); non-admins may not reassign ownership', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } - '404': { $ref: '#/components/responses/NotFound' } + '404': { description: 'not_found (container) or user_not_found (transfer target)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '422': { description: 'user_inactive — transfer target is not an active user', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } delete: operationId: delete_container tags: [Containers] diff --git a/create-a-container/routers/api/v1/__tests__/containers.owner.test.js b/create-a-container/routers/api/v1/__tests__/containers.owner.test.js new file mode 100644 index 00000000..3c4e3b4e --- /dev/null +++ b/create-a-container/routers/api/v1/__tests__/containers.owner.test.js @@ -0,0 +1,115 @@ +/** + * PUT /api/v1/sites/:siteId/containers/:id with `username` — admin-only + * ownership transfer. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../../tests/helpers/app'); +const { resetDb, closeDb, createUser, createApiKey } = require('../../../../tests/helpers/db'); +const { Site, Node, Container, ContainerCollaborator, ResourceRequest } = require('../../../../models'); + +describe('PUT container ownership transfer', () => { + let app; + let site; + let container; + let adminKey; + let owner; + let ownerKey; + let other; + let otherKey; + + beforeEach(async () => { + await resetDb(); + app = buildApp(); + // First user after resetDb() is auto-promoted to sysadmins. + const admin = await createUser({ admin: true }); + ({ plainKey: adminKey } = await createApiKey(admin)); + owner = await createUser(); + ({ plainKey: ownerKey } = await createApiKey(owner)); + other = await createUser(); + ({ plainKey: otherKey } = await createApiKey(other)); + site = await Site.create({ name: 's', internalDomain: 'ex.test' }); + const node = await Node.create({ siteId: site.id, name: 'n', nodeType: 'dummy' }); + container = await Container.create({ + hostname: 'box', + username: owner.uid, + nodeId: node.id, + siteId: site.id, + }); + }); + + afterAll(async () => { + await closeDb(); + }); + + function put(key, body) { + return request(app) + .put(`/api/v1/sites/${site.id}/containers/${container.id}`) + .set(...bearer(key)) + .send(body); + } + + test('an admin can transfer ownership and the previous owner loses access', async () => { + const res = await put(adminKey, { username: other.uid }); + expect(res.status).toBe(200); + expect(res.body.data.message).toBe(`Ownership transferred to ${other.uid}`); + await container.reload(); + expect(container.username).toBe(other.uid); + + const after = await request(app) + .get(`/api/v1/sites/${site.id}/containers/${container.id}`) + .set(...bearer(ownerKey)); + expect(after.status).toBe(404); + }); + + test('the owner cannot transfer ownership', async () => { + const res = await put(ownerKey, { username: other.uid }); + expect(res.status).toBe(403); + await container.reload(); + expect(container.username).toBe(owner.uid); + }); + + test('a collaborator cannot transfer ownership', async () => { + await ContainerCollaborator.create({ containerId: container.id, username: other.uid }); + const res = await put(otherKey, { username: other.uid }); + expect(res.status).toBe(403); + await container.reload(); + expect(container.username).toBe(owner.uid); + }); + + test('rejects an unknown user', async () => { + const res = await put(adminKey, { username: 'nobody' }); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('user_not_found'); + }); + + test('rejects an inactive user', async () => { + const inactive = await createUser({ status: 'pending' }); + const res = await put(adminKey, { username: inactive.uid }); + expect(res.status).toBe(422); + expect(res.body.error.code).toBe('user_inactive'); + }); + + test("removes the new owner's sharing grant", async () => { + await ContainerCollaborator.create({ containerId: container.id, username: other.uid }); + const res = await put(adminKey, { username: other.uid }); + expect(res.status).toBe(200); + expect(await ContainerCollaborator.count({ where: { containerId: container.id } })).toBe(0); + }); + + test('moves resource requests to the new owner', async () => { + await ResourceRequest.create({ + siteId: site.id, + hostname: 'box', + username: owner.uid, + resourceType: 'memory', + value: 16384, + status: 'approved', + reviewedAt: new Date(), + }); + const res = await put(adminKey, { username: other.uid }); + expect(res.status).toBe(200); + expect(await ResourceRequest.getApprovedResources(site.id, 'box', other.uid)).toEqual({ memory: 16384 }); + expect(await ResourceRequest.getApprovedResources(site.id, 'box', owner.uid)).toEqual({}); + }); +}); diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 97b243d0..06471f6c 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -15,7 +15,9 @@ const { Site, ExternalDomain, Job, + ResourceRequest, Setting, + User, Volume, Sequelize, sequelize, @@ -804,8 +806,7 @@ router.put( } } - // Admins may reassign the container to another user by passing `username`. - // Non-admins may not pass a different username — that is a 403. + // Admins may transfer ownership to another active user by passing `username`. let newOwnerUsername = null; if (bodyUsername !== undefined) { if (typeof bodyUsername !== 'string' || !bodyUsername.trim()) { @@ -815,6 +816,15 @@ router.put( throw new ApiError(403, 'forbidden', 'only admins may reassign container ownership'); } newOwnerUsername = bodyUsername.trim(); + if (newOwnerUsername !== container.username) { + const newOwner = await User.findOne({ where: { uid: newOwnerUsername }, attributes: ['status'] }); + if (!newOwner) { + throw new ApiError(404, 'user_not_found', `User "${newOwnerUsername}" does not exist`); + } + if (newOwner.status !== 'active') { + throw new ApiError(422, 'user_inactive', `User "${newOwnerUsername}" is not active`); + } + } } // Only recompute env/entrypoint when the request actually carried the key; @@ -829,7 +839,8 @@ router.put( ? entrypoint.trim() : null : container.entrypoint; - const ownerChanged = newOwnerUsername !== null && newOwnerUsername !== container.username; + const previousOwner = container.username; + const ownerChanged = newOwnerUsername !== null && newOwnerUsername !== previousOwner; const envChanged = envProvided && container.environmentVars !== envVarsJson; const entrypointChanged = entrypointProvided && container.entrypoint !== newEntrypoint; const volumesChanged = volumeAttaches.length > 0 || volumeDetachIds.length > 0; @@ -857,6 +868,27 @@ router.put( { transaction: t }, ); } + if (ownerChanged) { + // The new owner no longer needs a sharing grant on their own container. + await ContainerCollaborator.destroy({ + where: { containerId: container.id, username: newOwnerUsername }, + transaction: t, + }); + // Resource requests are keyed by (site, hostname, owner); move them so + // approved resources keep following the container. + await ResourceRequest.update( + { username: newOwnerUsername }, + { + where: { + siteId: site.id, + hostname: container.hostname, + username: previousOwner, + status: { [Sequelize.Op.in]: ['pending', 'approved'] }, + }, + transaction: t, + }, + ); + } if (needsReconfigureJob) { restartJob = await Job.create( { @@ -1009,7 +1041,9 @@ router.put( : 'Container is restarting' : pendingRestart ? 'Container updated — changes take effect on the next restart' - : 'Container updated'; + : ownerChanged + ? `Ownership transferred to ${newOwnerUsername}` + : 'Container updated'; return ok(res, { containerId: container.id, jobId: restartJob ? restartJob.id : null, diff --git a/mie-opensource-landing/docs/admins/core-concepts/containers.md b/mie-opensource-landing/docs/admins/core-concepts/containers.md index e15a9d4c..68661155 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/containers.md +++ b/mie-opensource-landing/docs/admins/core-concepts/containers.md @@ -16,6 +16,18 @@ Users in the **ldapusers** group can SSH into any container using their cluster | **Creating** | Being provisioned | | **Failed** | Creation or startup failed | +## Ownership Transfer + +Admins can hand a container to another active user from the container's edit page (**Ownership** → **Transfer**), e.g. when the owner leaves the organization. + +| Moves to the new owner | Not changed | +|---|---| +| Container record and Proxmox owner tag | Running container (no restart) | +| Approved/pending resource requests | Volume data (host paths are fixed at creation) | +| | Other collaborators | + +The new owner's sharing grant, if any, is removed. The previous owner loses access unless the new owner shares it back. API (admin only): `PUT /api/v1/sites/{siteId}/containers/{id}` with `{ "username": "" }`. + ## Volumes Containers can attach persistent **[volumes](volumes.md)** — bind-mount