Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
server/api: add change-user-type to add/remove collaborators
  • Loading branch information
haraldschilly committed Dec 15, 2025
commit 144733139081518528ce7985f3ae4ddb6dcf1ab8
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,47 @@ describe("_user_set_query_project_users sanitizer", () => {
sanitizeUserSetQueryProjectUsers(
{
users: {
[otherId]: { hide: true },
[otherId]: { upgrades: { memory: 1024 } },
},
},
accountId,
),
).toThrow("users set queries may only modify the requesting account");
).toThrow(
"users set queries may only change upgrades for the requesting account",
);
});

test("allows system-style updates when no account_id is provided", () => {
const value = sanitizeUserSetQueryProjectUsers({
users: {
[accountId]: { hide: false, ssh_keys: {} },
},
});
expect(value).toEqual({
[accountId]: { hide: false, ssh_keys: {} },
});
});

test("allows system operations to set group to owner", () => {
const value = sanitizeUserSetQueryProjectUsers({
users: {
[accountId]: { group: "owner", hide: false },
},
});
expect(value).toEqual({
[accountId]: { group: "owner", hide: false },
});
});

test("allows system operations to set group to collaborator", () => {
const value = sanitizeUserSetQueryProjectUsers({
users: {
[accountId]: { group: "collaborator" },
},
});
expect(value).toEqual({
[accountId]: { group: "collaborator" },
});
});

test("rejects group changes", () => {
Expand All @@ -55,6 +90,32 @@ describe("_user_set_query_project_users sanitizer", () => {
).toThrow("changing collaborator group via user_set_query is not allowed");
});

test("rejects invalid group values in system operations", () => {
expect(() =>
sanitizeUserSetQueryProjectUsers({
users: {
[accountId]: { group: "admin" },
},
}),
).toThrow(
"invalid group value 'admin' - must be 'owner' or 'collaborator'",
);
});

test("allows hiding another collaborator", () => {
const value = sanitizeUserSetQueryProjectUsers(
{
users: {
[otherId]: { hide: true },
},
},
accountId,
);
expect(value).toEqual({
[otherId]: { hide: true },
});
});

test("rejects invalid upgrade field", () => {
expect(() =>
sanitizeUserSetQueryProjectUsers(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,28 @@ import {
is_object,
is_valid_uuid_string,
} from "@cocalc/util/misc";
import { type UserGroup } from "@cocalc/util/project-ownership";

type AllowedUserFields = {
group?: UserGroup;
hide?: boolean;
upgrades?: Record<string, unknown>;
ssh_keys?: Record<string, Record<string, unknown> | undefined>;
};

function ensureAllowedKeys(user: Record<string, unknown>): void {
function ensureAllowedKeys(
user: Record<string, unknown>,
allowGroupChanges: boolean,
): void {
const allowed = new Set(["hide", "upgrades", "ssh_keys"]);
for (const key of Object.keys(user)) {
if (key === "group") {
throw Error(
"changing collaborator group via user_set_query is not allowed",
);
if (!allowGroupChanges) {
throw Error(
"changing collaborator group via user_set_query is not allowed",
);
}
continue;
}
if (!allowed.has(key)) {
throw Error(`unknown field '${key}'`);
Expand Down Expand Up @@ -79,12 +87,14 @@ function sanitizeSshKeys(
*/
export function sanitizeUserSetQueryProjectUsers(
obj: { users?: unknown } | undefined,
account_id: string,
account_id?: string,
): Record<string, AllowedUserFields> | undefined {
if (obj?.users == null) {
return undefined;
}
assert_valid_account_id(account_id);
if (account_id != null) {
assert_valid_account_id(account_id);
}
if (!is_object(obj.users)) {
throw Error("users must be an object");
}
Expand All @@ -96,27 +106,49 @@ export function sanitizeUserSetQueryProjectUsers(
if (!is_valid_uuid_string(id)) {
throw Error(`invalid account_id '${id}'`);
}
if (id !== account_id) {
throw Error("users set queries may only modify the requesting account");
}
const user = usersInput[id];
if (!is_object(user)) {
throw Error("user entry must be an object");
}

ensureAllowedKeys(user as Record<string, unknown>);
const isSelf = account_id == null || id === account_id;
ensureAllowedKeys(user as Record<string, unknown>, account_id == null);

const entry: AllowedUserFields = {};
if ("group" in user) {
if (account_id != null) {
throw Error(
"changing collaborator group via user_set_query is not allowed",
);
}
const group = (user as any).group;
if (group !== "owner" && group !== "collaborator") {
throw Error(
`invalid group value '${group}' - must be 'owner' or 'collaborator'`,
);
}
entry.group = group;
}
if ("hide" in user) {
if (typeof (user as any).hide !== "boolean") {
throw Error("invalid type for field 'hide'");
}
entry.hide = (user as any).hide;
}
if ("upgrades" in user) {
if (!isSelf) {
throw Error(
"users set queries may only change upgrades for the requesting account",
);
}
entry.upgrades = sanitizeUpgrades((user as any).upgrades);
}
if ("ssh_keys" in user) {
if (!isSelf) {
throw Error(
"users set queries may only change ssh_keys for the requesting account",
);
}
entry.ssh_keys = sanitizeSshKeys((user as any).ssh_keys);
}
sanitized[id] = entry;
Expand Down
2 changes: 1 addition & 1 deletion src/packages/database/postgres/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export interface PostgreSQL extends EventEmitter {

_user_set_query_project_users(
obj: any,
account_id: string,
account_id?: string,
): Record<string, unknown> | undefined;

user_is_in_project_group(opts: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { z } from "../../../framework";

import { FailedAPIOperationSchema, OkAPIOperationSchema } from "../../common";

import { ProjectIdSchema } from "../common";
import { AccountIdSchema } from "../../accounts/common";

export const UserGroupSchema = z
.enum(["owner", "collaborator"])
.describe("Project user role (owner or collaborator).");

export const ChangeProjectUserTypeInputSchema = z
.object({
project_id: ProjectIdSchema,
target_account_id: AccountIdSchema.describe(
"Account id of the user whose role will be changed.",
),
new_group: UserGroupSchema.describe(
"New role to assign; must be owner or collaborator.",
),
})
.describe(
"Change a collaborator's role in a project. Only owners can promote or demote users; validation enforces ownership rules (e.g., cannot demote the last owner).",
);

export const ChangeProjectUserTypeOutputSchema = z.union([
FailedAPIOperationSchema,
OkAPIOperationSchema,
]);

export type ChangeProjectUserTypeInput = z.infer<
typeof ChangeProjectUserTypeInputSchema
>;
export type ChangeProjectUserTypeOutput = z.infer<
typeof ChangeProjectUserTypeOutputSchema
>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** @jest-environment node */

import { createMocks } from "lib/api/test-framework";
import handler from "./change-user-type";

jest.mock("@cocalc/server/projects/collaborators", () => ({
changeUserType: jest.fn(),
}));
jest.mock("lib/account/get-account", () => jest.fn());

describe("/api/v2/projects/collaborators/change-user-type", () => {
beforeEach(() => {
jest.clearAllMocks();
});

test("unauthenticated request returns error", async () => {
const getAccountId = require("lib/account/get-account");
getAccountId.mockResolvedValue(undefined);

const { req, res } = createMocks({
method: "POST",
url: "/api/v2/projects/collaborators/change-user-type",
body: {
project_id: "00000000-0000-0000-0000-000000000000",
target_account_id: "11111111-1111-1111-1111-111111111111",
new_group: "owner",
},
});

await expect(handler(req, res)).resolves.not.toThrow();

const collaborators = require("@cocalc/server/projects/collaborators");
expect(collaborators.changeUserType).not.toHaveBeenCalled();

const data = res._getJSONData();
expect(data).toHaveProperty("error");
expect(data.error).toContain("signed in");
});

test("authenticated request calls changeUserType", async () => {
const mockAccountId = "22222222-2222-2222-2222-222222222222";
const mockProjectId = "00000000-0000-0000-0000-000000000000";
const mockTargetAccountId = "11111111-1111-1111-1111-111111111111";

const getAccountId = require("lib/account/get-account");
getAccountId.mockResolvedValue(mockAccountId);

const collaborators = require("@cocalc/server/projects/collaborators");
collaborators.changeUserType.mockResolvedValue(undefined);

const { req, res } = createMocks({
method: "POST",
url: "/api/v2/projects/collaborators/change-user-type",
body: {
project_id: mockProjectId,
target_account_id: mockTargetAccountId,
new_group: "collaborator",
},
});

await handler(req, res);

expect(collaborators.changeUserType).toHaveBeenCalledWith({
account_id: mockAccountId,
opts: {
project_id: mockProjectId,
target_account_id: mockTargetAccountId,
new_group: "collaborator",
},
});

const data = res._getJSONData();
expect(data).toHaveProperty("status");
expect(data.status).toBe("ok");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
API endpoint to change a user's collaborator type on an existing project.

Permissions checks are performed by the underlying API call and are NOT
executed at this stage.

*/
import { changeUserType } from "@cocalc/server/projects/collaborators";

import getAccountId from "lib/account/get-account";
import getParams from "lib/api/get-params";
import { apiRoute, apiRouteOperation } from "lib/api";
import { OkStatus } from "lib/api/status";
import {
ChangeProjectUserTypeInputSchema,
ChangeProjectUserTypeOutputSchema,
} from "lib/api/schema/projects/collaborators/change-user-type";

async function handle(req, res) {
const { project_id, target_account_id, new_group } = getParams(req);
const client_account_id = await getAccountId(req);

try {
if (!client_account_id) {
throw Error("must be signed in");
}

await changeUserType({
account_id: client_account_id,
opts: { project_id, target_account_id, new_group },
});

res.json(OkStatus);
} catch (err) {
res.json({ error: err.message });
}
}

export default apiRoute({
changeProjectUserType: apiRouteOperation({
method: "POST",
openApiOperation: {
tags: ["Projects", "Admin"],
},
})
.input({
contentType: "application/json",
body: ChangeProjectUserTypeInputSchema,
})
.outputs([
{
status: 200,
contentType: "application/json",
body: ChangeProjectUserTypeOutputSchema,
},
])
.handler(handle),
});
Loading