Skip to content
Open
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
1 change: 1 addition & 0 deletions src/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"Bash(gh pr view:*)",
"Bash(gh:*)",
"Bash(git add:*)",
"Bash(git grep:*)",
"Bash(git branch:*)",
"Bash(git checkout:*)",
"Bash(git commit:*)",
Expand Down
14 changes: 14 additions & 0 deletions src/packages/conat/hub/api/projects.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { authFirstRequireAccount } from "./util";
import { type CreateProjectOptions } from "@cocalc/util/db-schema/projects";
import { type UserCopyOptions } from "@cocalc/util/db-schema/projects";
import { type UserGroup } from "@cocalc/util/project-ownership";
import {
type ProjectState,
type ProjectStatus,
Expand All @@ -13,6 +14,7 @@ export const projects = {
addCollaborator: authFirstRequireAccount,
inviteCollaborator: authFirstRequireAccount,
inviteCollaboratorWithoutAccount: authFirstRequireAccount,
changeUserType: authFirstRequireAccount,
setQuotas: authFirstRequireAccount,
start: authFirstRequireAccount,
stop: authFirstRequireAccount,
Expand Down Expand Up @@ -95,6 +97,18 @@ export interface Projects {
};
}) => Promise<void>;

changeUserType: ({
account_id,
opts,
}: {
account_id?: string;
opts: {
project_id: string;
target_account_id: string;
new_group: UserGroup;
};
}) => Promise<void>;

setQuotas: (opts: {
account_id?: string;
project_id: string;
Expand Down
63 changes: 17 additions & 46 deletions src/packages/database/postgres-user-queries.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ lodash = require('lodash')
{defaults} = misc = require('@cocalc/util/misc')
required = defaults.required

{PROJECT_UPGRADES, SCHEMA, OPERATORS, isToOperand} = require('@cocalc/util/schema')
{SCHEMA, OPERATORS, isToOperand} = require('@cocalc/util/schema')
{queryIsCmp, userGetQueryFilter} = require("./user-query/user-get-query")

{updateRetentionData} = require('./postgres/retention')
{sanitizeManageUsersOwnerOnly} = require('./postgres/project/manage-users-owner-only')
{sanitizeUserSetQueryProjectUsers} = require('./postgres/project/user-set-query-project-users')

{ checkProjectName } = require("@cocalc/util/db-schema/name-rules");
{callback2} = require('@cocalc/util/async-utils')
Expand Down Expand Up @@ -793,51 +795,14 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext
y[k0] = v0

_user_set_query_project_users: (obj, account_id) =>
dbg = @_dbg("_user_set_query_project_users")
if not obj.users?
# nothing to do -- not changing users.
return
##dbg("disabled")
##return obj.users
# - ensures all keys of users are valid uuid's (though not that they are valid users).
# - and format is:
# {group:'owner' or 'collaborator', hide:bool, upgrades:{a map}}
# with valid upgrade fields.
upgrade_fields = PROJECT_UPGRADES.params
users = {}
# TODO: we obviously should check that a user is only changing the part
# of this object involving themselves... or adding/removing collaborators.
# That is not currently done below. TODO TODO TODO SECURITY.
for id, x of obj.users
if misc.is_valid_uuid_string(id)
for key in misc.keys(x)
if key not in ['group', 'hide', 'upgrades', 'ssh_keys']
throw Error("unknown field '#{key}")
if x.group? and (x.group not in ['owner', 'collaborator'])
throw Error("invalid value for field 'group'")
if x.hide? and typeof(x.hide) != 'boolean'
throw Error("invalid type for field 'hide'")
if x.upgrades?
if not misc.is_object(x.upgrades)
throw Error("invalid type for field 'upgrades'")
for k,_ of x.upgrades
if not upgrade_fields[k]
throw Error("invalid upgrades field '#{k}'")
if x.ssh_keys
# do some checks.
if not misc.is_object(x.ssh_keys)
throw Error("ssh_keys must be an object")
for fingerprint, key of x.ssh_keys
if not key # deleting
continue
if not misc.is_object(key)
throw Error("each key in ssh_keys must be an object")
for k, v of key
# the two dates are just numbers not actual timestamps...
if k not in ['title', 'value', 'creation_date', 'last_use_date']
throw Error("invalid ssh_keys field '#{k}'")
users[id] = x
return users
return sanitizeUserSetQueryProjectUsers(obj, account_id)

_user_set_query_project_manage_users_owner_only: (obj, account_id) =>
# This hook is called from the schema functional substitution to validate
# the manage_users_owner_only flag. This must be synchronous - async validation
# (permission checks) is done in the check_hook instead.
# Just do basic type validation and sanitization here
return sanitizeManageUsersOwnerOnly(obj.manage_users_owner_only)

project_action: (opts) =>
opts = defaults opts,
Expand Down Expand Up @@ -933,6 +898,12 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext
cb("Only the owner of the project can currently change the project name.")
return

if new_val?.manage_users_owner_only? and new_val.manage_users_owner_only != old_val?.manage_users_owner_only
# Permission is enforced in the set-field interceptor; nothing to do here.
# Leaving this block for clarity and to avoid silent bypass if future callers
# modify manage_users_owner_only via another path.
dbg("manage_users_owner_only change requested")

if new_val?.action_request? and JSON.stringify(new_val.action_request.time) != JSON.stringify(old_val?.action_request?.time)
# Requesting an action, e.g., save, restart, etc.
dbg("action_request -- #{misc.to_json(new_val.action_request)}")
Expand Down
74 changes: 74 additions & 0 deletions src/packages/database/postgres/manage-users-owner-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
* License: MS-RSL – see LICENSE.md for details
*/

import getPool, { initEphemeralDatabase } from "@cocalc/database/pool";
import { db } from "@cocalc/database";
import { uuid } from "@cocalc/util/misc";

let pool: ReturnType<typeof getPool> | undefined;

beforeAll(async () => {
await initEphemeralDatabase();
pool = getPool();
}, 15000);

afterAll(async () => {
if (pool) {
await pool.end();
}
});

async function insertProject(opts: {
projectId: string;
ownerId: string;
collaboratorId: string;
}) {
const { projectId, ownerId, collaboratorId } = opts;
if (!pool) {
throw Error("Pool not initialized");
}
await pool.query("INSERT INTO projects(project_id, users) VALUES ($1, $2)", [
projectId,
{
[ownerId]: { group: "owner" },
[collaboratorId]: { group: "collaborator" },
},
]);
}

describe("manage_users_owner_only set hook", () => {
const projectId = uuid();
const ownerId = uuid();
const collaboratorId = uuid();

beforeAll(async () => {
await insertProject({ projectId, ownerId, collaboratorId });
});

test("owner can set manage_users_owner_only", async () => {
const value = await db()._user_set_query_project_manage_users_owner_only(
{ project_id: projectId, manage_users_owner_only: true },
ownerId,
);
expect(value).toBe(true);
});

test("collaborator call returns sanitized value (permission enforced elsewhere)", async () => {
const value = await db()._user_set_query_project_manage_users_owner_only(
{ project_id: projectId, manage_users_owner_only: true },
collaboratorId,
);
expect(value).toBe(true);
});

test("invalid type is rejected", async () => {
expect(() =>
db()._user_set_query_project_manage_users_owner_only(
{ project_id: projectId, manage_users_owner_only: "yes" as any },
ownerId,
),
).toThrow("manage_users_owner_only must be a boolean");
});
});
31 changes: 31 additions & 0 deletions src/packages/database/postgres/project/manage-users-owner-only.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
* License: MS-RSL – see LICENSE.md for details
*/

export function sanitizeManageUsersOwnerOnly(
value: unknown,
): boolean | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "object") {
// Allow nested shape { manage_users_owner_only: boolean } from callers that wrap input.
const candidate = (value as any).manage_users_owner_only;
if (candidate !== undefined) {
return sanitizeManageUsersOwnerOnly(candidate);
}
// Allow Immutable.js style get("manage_users_owner_only")
const getter = (value as any).get;
if (typeof getter === "function") {
const maybe = getter.call(value, "manage_users_owner_only");
if (maybe !== undefined) {
return sanitizeManageUsersOwnerOnly(maybe);
}
}
}
if (typeof value !== "boolean") {
throw Error("manage_users_owner_only must be a boolean");
}
return value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
* License: MS-RSL – see LICENSE.md for details
*/

import { uuid } from "@cocalc/util/misc";
import { sanitizeUserSetQueryProjectUsers } from "./user-set-query-project-users";

describe("_user_set_query_project_users sanitizer", () => {
const accountId = uuid();
const otherId = uuid();

test("returns undefined when users is not provided", () => {
const value = sanitizeUserSetQueryProjectUsers({}, accountId);
expect(value).toBeUndefined();
});

test("allows updating own hide and upgrades", () => {
const value = sanitizeUserSetQueryProjectUsers(
{
users: {
[accountId]: { hide: true, upgrades: { memory: 1024 } },
},
},
accountId,
);
expect(value).toEqual({
[accountId]: { hide: true, upgrades: { memory: 1024 } },
});
});

test("rejects modifying another account", () => {
expect(() =>
sanitizeUserSetQueryProjectUsers(
{
users: {
[otherId]: { upgrades: { memory: 1024 } },
},
},
accountId,
),
).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", () => {
expect(() =>
sanitizeUserSetQueryProjectUsers(
{
users: {
[accountId]: { group: "owner" },
},
},
accountId,
),
).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(
{
users: {
[accountId]: { upgrades: { invalidQuota: 1 } },
},
},
accountId,
),
).toThrow("invalid upgrades field 'invalidQuota'");
});
});
Loading