Skip to content
Open
Next Next commit
project/collaborators: implement ownership management -- #7718
  • Loading branch information
haraldschilly committed Dec 4, 2025
commit 2552dd77a1dff4c9a07b030db77a90779dc5af35
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";

export const projects = {
createProject: authFirstRequireAccount,
Expand All @@ -9,6 +10,7 @@ export const projects = {
addCollaborator: authFirstRequireAccount,
inviteCollaborator: authFirstRequireAccount,
inviteCollaboratorWithoutAccount: authFirstRequireAccount,
changeUserType: authFirstRequireAccount,
setQuotas: authFirstRequireAccount,
start: authFirstRequireAccount,
stop: authFirstRequireAccount,
Expand Down Expand Up @@ -87,6 +89,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
14 changes: 14 additions & 0 deletions src/packages/database/postgres-user-queries.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ required = defaults.required
{queryIsCmp, userGetQueryFilter} = require("./user-query/user-get-query")

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

{ checkProjectName } = require("@cocalc/util/db-schema/name-rules");
{callback2} = require('@cocalc/util/async-utils')
Expand Down Expand Up @@ -839,6 +840,13 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext
users[id] = x
return users

_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,
project_id : required
Expand Down Expand Up @@ -933,6 +941,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
85 changes: 85 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,85 @@
/*
* 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;
let dbAvailable = true;

beforeAll(async () => {
try {
await initEphemeralDatabase();
pool = getPool();
} catch (err) {
// Skip locally if postgres is unavailable.
dbAvailable = false;
console.warn("Skipping manage_users_owner_only tests: " + err);
}
}, 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 () => {
if (!dbAvailable) return;
await insertProject({ projectId, ownerId, collaboratorId });
});

test("owner can set manage_users_owner_only", async () => {
if (!dbAvailable) return;
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 () => {
if (!dbAvailable) return;
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 () => {
if (!dbAvailable) return;
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;
}
15 changes: 11 additions & 4 deletions src/packages/database/postgres/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ export interface QueryOptions<T = UntypedQueryResult> {
cb?: CB<QueryRows<T>>;
}

export interface AsyncQueryOptions<T = UntypedQueryResult>
extends Omit<QueryOptions<T>, "cb"> {}
export interface AsyncQueryOptions<T = UntypedQueryResult> extends Omit<
QueryOptions<T>,
"cb"
> {}

export interface UserQueryOptions {
client_id?: string; // if given, uses to control number of queries at once by one client.
Expand Down Expand Up @@ -406,8 +408,13 @@ export interface PostgreSQL extends EventEmitter {
webapp_error(opts: object);

set_project_settings(opts: { project_id: string; settings: object; cb?: CB });

uncaught_exception: (err:any) => void;

_user_set_query_project_manage_users_owner_only(
obj: any,
account_id: string,
): string | undefined;

uncaught_exception: (err: any) => void;
}

// This is an extension of BaseProject in projects/control/base.ts
Expand Down
2 changes: 1 addition & 1 deletion src/packages/frontend/antd-bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function parse_bsStyle(props: {
let type =
props.bsStyle == null
? "default"
: BS_STYLE_TO_TYPE[props.bsStyle] ?? "default";
: (BS_STYLE_TO_TYPE[props.bsStyle] ?? "default");

let style: React.CSSProperties | undefined = undefined;
// antd has no analogue of "success" & "warning", it's not clear to me what
Expand Down
12 changes: 11 additions & 1 deletion src/packages/frontend/client/project-collaborators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* License: MS-RSL – see LICENSE.md for details
*/

// cSpell:ignore replyto collabs noncloud

import type { ConatClient } from "@cocalc/frontend/conat/client";
import type { AddCollaborator } from "@cocalc/conat/hub/api/projects";

Expand Down Expand Up @@ -57,10 +59,18 @@ export class ProjectCollaborators {
public async add_collaborator(
opts: AddCollaborator,
): Promise<{ project_id?: string | string[] }> {
// project_id is a single string or possibly an array of project_id's
// project_id is a single string or possibly an array of project_id's
// in case of a token.
return await this.conat.hub.projects.addCollaborator({
opts,
});
}

public async change_user_type(opts: {
project_id: string;
target_account_id: string;
new_group: "owner" | "collaborator";
}): Promise<void> {
return await this.conat.hub.projects.changeUserType({ opts });
}
}
55 changes: 47 additions & 8 deletions src/packages/frontend/collaborators/add-collaborators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
Add collaborators to a project
*/

// cSpell:ignore replyto noncloud collabs

import { Alert, Button, Input, Select } from "antd";
import { useIntl } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";

import { labels } from "@cocalc/frontend/i18n";
import {
React,
Expand All @@ -17,12 +20,19 @@ import {
useIsMountedRef,
useMemo,
useRef,
useRedux,
useTypedRedux,
useState,
} from "../app-framework";
import { Well } from "../antd-bootstrap";
import { A, Icon, Loading, ErrorDisplay, Gap } from "../components";
import { webapp_client } from "../webapp-client";
import { Well } from "@cocalc/frontend/antd-bootstrap";
import {
A,
Icon,
Loading,
ErrorDisplay,
Gap,
} from "@cocalc/frontend/components";
import { webapp_client } from "@cocalc/frontend/webapp-client";
import { SITE_NAME } from "@cocalc/util/theme";
import {
contains_url,
Expand All @@ -34,10 +44,10 @@ import {
search_match,
search_split,
} from "@cocalc/util/misc";
import { Project } from "../projects/store";
import { Avatar } from "../account/avatar/avatar";
import { Project } from "@cocalc/frontend/projects/store";
import { Avatar } from "@cocalc/frontend/account/avatar/avatar";
import { ProjectInviteTokens } from "./project-invite-tokens";
import { alert_message } from "../alerts";
import { alert_message } from "@cocalc/frontend/alerts";
import { useStudentProjectFunctionality } from "@cocalc/frontend/course";
import Sandbox from "./sandbox";
import track from "@cocalc/frontend/user-tracking";
Expand Down Expand Up @@ -104,6 +114,20 @@ export const AddCollaborators: React.FC<Props> = ({
() => project_map?.get(project_id),
[project_id, project_map],
);
const get_account_id = useRedux("account", "get_account_id");
const current_account_id = get_account_id();
const strict_collaborator_management =
useTypedRedux("customize", "strict_collaborator_management") ?? false;
const manage_users_owner_only =
strict_collaborator_management ||
(project?.get("manage_users_owner_only") ?? false);
const current_user_group = project?.getIn([
"users",
current_account_id,
"group",
]);
const isOwner = current_user_group === "owner";
const collaboratorManagementRestricted = manage_users_owner_only && !isOwner;

// search that user has typed in so far
const [search, set_search] = useState<string>("");
Expand Down Expand Up @@ -257,7 +281,7 @@ export const AddCollaborators: React.FC<Props> = ({
// react rendered version of this that is much nicer (with pictures!) someday.
const extra: string[] = [];
if (r.account_id != null && user_map.get(r.account_id)) {
extra.push("Collaborator");
extra.push(intl.formatMessage(labels.collaborator));
}
if (r.last_active) {
extra.push(`Active ${new Date(r.last_active).toLocaleDateString()}`);
Expand Down Expand Up @@ -691,6 +715,21 @@ export const AddCollaborators: React.FC<Props> = ({
return <div></div>;
}

if (collaboratorManagementRestricted) {
return (
<Alert
type="info"
showIcon={false}
message={
<FormattedMessage
id="project.collaborators.add.owner_only_setting"
defaultMessage="Only project owners can add collaborators when owner-only management is enabled."
/>
}
/>
);
}

return (
<div
style={isFlyout ? { paddingLeft: "5px", paddingRight: "5px" } : undefined}
Expand Down
Loading
Loading