Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex flex-col gap-4">
<p className="text-sm">
Current owner: <strong>{owner}</strong>
</p>
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label="New owner"
placeholder="Enter a username"
Comment on lines +57 to +64
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
value={value}
error={error || undefined}
hasError={!!error}
onChange={(e) => {
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);
}
}}
/>
</div>
<Button
type="button"
variant="outline"
className="cursor-pointer"
leftIcon={<UserRoundCog className="size-4" />}
disabled={!canSubmit}
onClick={() => setConfirmOpen(true)}
>
Transfer
</Button>
</div>

<Modal open={confirmOpen} onOpenChange={setConfirmOpen}>
<ModalHeader>
<ModalTitle>Transfer ownership?</ModalTitle>
<ModalClose />
</ModalHeader>
<ModalBody>
<p className="text-sm">
<strong>{hostname}</strong> will be owned by <strong>{newOwner}</strong>. Its approved
resources and volume data move with it. {owner} will lose access unless the new owner
shares it with them.
</p>
</ModalBody>
<ModalFooter>
<Button
type="button"
variant="ghost"
className="cursor-pointer"
onClick={() => setConfirmOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="danger"
className="cursor-pointer"
isLoading={transfer.isPending}
onClick={() => transfer.mutate()}
>
Transfer ownership
</Button>
</ModalFooter>
</Modal>
</div>
);
}
5 changes: 5 additions & 0 deletions create-a-container/client/src/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContainerNewBootstrap>(`/api/v1/sites/${siteId}/containers/new`),
containerMetadata: (siteId: number | string, image: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(value: T, delay = 500): T {
Expand Down Expand Up @@ -983,6 +984,21 @@ export function ContainerFormPage() {
})}
</CardContent>
</Card>
{isEdit && container && session?.isAdmin && (
<Card padding="none" className={sectionCardClass}>
<CardHeader className={sectionHeaderClass}>
<CardTitle className="text-base">Ownership</CardTitle>
</CardHeader>
<CardContent className={sectionContentClass}>
<TransferOwnership
siteId={siteId!}
containerId={container.id}
hostname={container.hostname}
owner={container.owner}
/>
</CardContent>
</Card>
)}
<Card padding="none" className={sectionCardClass}>
<CardHeader className={sectionHeaderClass}>
<CardTitle className="text-base">Sharing</CardTitle>
Expand Down
8 changes: 6 additions & 2 deletions create-a-container/openapi.v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand Down Expand Up @@ -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]
Expand Down
115 changes: 115 additions & 0 deletions create-a-container/routers/api/v1/__tests__/containers.owner.test.js
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +65 to +67
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({});
});
});
42 changes: 38 additions & 4 deletions create-a-container/routers/api/v1/containers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const {
Site,
ExternalDomain,
Job,
ResourceRequest,
Setting,
User,
Volume,
Sequelize,
sequelize,
Expand Down Expand Up @@ -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()) {
Expand All @@ -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;
Expand All @@ -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;
Comment on lines +842 to +843
const envChanged = envProvided && container.environmentVars !== envVarsJson;
const entrypointChanged = entrypointProvided && container.entrypoint !== newEntrypoint;
const volumesChanged = volumeAttaches.length > 0 || volumeDetachIds.length > 0;
Expand Down Expand Up @@ -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,
},
);
Comment thread
Copilot marked this conversation as resolved.
}
if (needsReconfigureJob) {
restartJob = await Job.create(
{
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading