Skip to content

Commit 970bf84

Browse files
committed
database/project collabs: decaff _user_set_query_project_users, fix TODO, add simple tests
1 parent d43af72 commit 970bf84

File tree

4 files changed

+204
-46
lines changed

4 files changed

+204
-46
lines changed

src/packages/database/postgres-user-queries.coffee

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ lodash = require('lodash')
2626
{defaults} = misc = require('@cocalc/util/misc')
2727
required = defaults.required
2828

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

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

3536
{ checkProjectName } = require("@cocalc/util/db-schema/name-rules");
3637
{callback2} = require('@cocalc/util/async-utils')
@@ -794,51 +795,7 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext
794795
y[k0] = v0
795796

796797
_user_set_query_project_users: (obj, account_id) =>
797-
dbg = @_dbg("_user_set_query_project_users")
798-
if not obj.users?
799-
# nothing to do -- not changing users.
800-
return
801-
##dbg("disabled")
802-
##return obj.users
803-
# - ensures all keys of users are valid uuid's (though not that they are valid users).
804-
# - and format is:
805-
# {group:'owner' or 'collaborator', hide:bool, upgrades:{a map}}
806-
# with valid upgrade fields.
807-
upgrade_fields = PROJECT_UPGRADES.params
808-
users = {}
809-
# TODO: we obviously should check that a user is only changing the part
810-
# of this object involving themselves... or adding/removing collaborators.
811-
# That is not currently done below. TODO TODO TODO SECURITY.
812-
for id, x of obj.users
813-
if misc.is_valid_uuid_string(id)
814-
for key in misc.keys(x)
815-
if key not in ['group', 'hide', 'upgrades', 'ssh_keys']
816-
throw Error("unknown field '#{key}")
817-
if x.group? and (x.group not in ['owner', 'collaborator'])
818-
throw Error("invalid value for field 'group'")
819-
if x.hide? and typeof(x.hide) != 'boolean'
820-
throw Error("invalid type for field 'hide'")
821-
if x.upgrades?
822-
if not misc.is_object(x.upgrades)
823-
throw Error("invalid type for field 'upgrades'")
824-
for k,_ of x.upgrades
825-
if not upgrade_fields[k]
826-
throw Error("invalid upgrades field '#{k}'")
827-
if x.ssh_keys
828-
# do some checks.
829-
if not misc.is_object(x.ssh_keys)
830-
throw Error("ssh_keys must be an object")
831-
for fingerprint, key of x.ssh_keys
832-
if not key # deleting
833-
continue
834-
if not misc.is_object(key)
835-
throw Error("each key in ssh_keys must be an object")
836-
for k, v of key
837-
# the two dates are just numbers not actual timestamps...
838-
if k not in ['title', 'value', 'creation_date', 'last_use_date']
839-
throw Error("invalid ssh_keys field '#{k}'")
840-
users[id] = x
841-
return users
798+
return sanitizeUserSetQueryProjectUsers(obj, account_id)
842799

843800
_user_set_query_project_manage_users_owner_only: (obj, account_id) =>
844801
# This hook is called from the schema functional substitution to validate
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import { uuid } from "@cocalc/util/misc";
7+
import { sanitizeUserSetQueryProjectUsers } from "./user-set-query-project-users";
8+
9+
describe("_user_set_query_project_users sanitizer", () => {
10+
const accountId = uuid();
11+
const otherId = uuid();
12+
13+
test("returns undefined when users is not provided", () => {
14+
const value = sanitizeUserSetQueryProjectUsers({}, accountId);
15+
expect(value).toBeUndefined();
16+
});
17+
18+
test("allows updating own hide and upgrades", () => {
19+
const value = sanitizeUserSetQueryProjectUsers(
20+
{
21+
users: {
22+
[accountId]: { hide: true, upgrades: { memory: 1024 } },
23+
},
24+
},
25+
accountId,
26+
);
27+
expect(value).toEqual({
28+
[accountId]: { hide: true, upgrades: { memory: 1024 } },
29+
});
30+
});
31+
32+
test("rejects modifying another account", () => {
33+
expect(() =>
34+
sanitizeUserSetQueryProjectUsers(
35+
{
36+
users: {
37+
[otherId]: { hide: true },
38+
},
39+
},
40+
accountId,
41+
),
42+
).toThrow("users set queries may only modify the requesting account");
43+
});
44+
45+
test("rejects group changes", () => {
46+
expect(() =>
47+
sanitizeUserSetQueryProjectUsers(
48+
{
49+
users: {
50+
[accountId]: { group: "owner" },
51+
},
52+
},
53+
accountId,
54+
),
55+
).toThrow("changing collaborator group via user_set_query is not allowed");
56+
});
57+
58+
test("rejects invalid upgrade field", () => {
59+
expect(() =>
60+
sanitizeUserSetQueryProjectUsers(
61+
{
62+
users: {
63+
[accountId]: { upgrades: { invalidQuota: 1 } },
64+
},
65+
},
66+
accountId,
67+
),
68+
).toThrow("invalid upgrades field 'invalidQuota'");
69+
});
70+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import { PROJECT_UPGRADES } from "@cocalc/util/schema";
7+
import {
8+
assert_valid_account_id,
9+
is_object,
10+
is_valid_uuid_string,
11+
} from "@cocalc/util/misc";
12+
13+
type AllowedUserFields = {
14+
hide?: boolean;
15+
upgrades?: Record<string, unknown>;
16+
ssh_keys?: Record<string, Record<string, unknown> | undefined>;
17+
};
18+
19+
function ensureAllowedKeys(user: Record<string, unknown>): void {
20+
const allowed = new Set(["hide", "upgrades", "ssh_keys"]);
21+
for (const key of Object.keys(user)) {
22+
if (key === "group") {
23+
throw Error(
24+
"changing collaborator group via user_set_query is not allowed",
25+
);
26+
}
27+
if (!allowed.has(key)) {
28+
throw Error(`unknown field '${key}'`);
29+
}
30+
}
31+
}
32+
33+
function sanitizeUpgrades(upgrades: unknown): Record<string, unknown> {
34+
if (!is_object(upgrades)) {
35+
throw Error("invalid type for field 'upgrades'");
36+
}
37+
const allowedUpgrades = PROJECT_UPGRADES.params;
38+
for (const key of Object.keys(upgrades)) {
39+
if (!Object.prototype.hasOwnProperty.call(allowedUpgrades, key)) {
40+
throw Error(`invalid upgrades field '${key}'`);
41+
}
42+
}
43+
return upgrades as Record<string, unknown>;
44+
}
45+
46+
function sanitizeSshKeys(
47+
ssh_keys: unknown,
48+
): Record<string, Record<string, unknown> | undefined> {
49+
if (!is_object(ssh_keys)) {
50+
throw Error("ssh_keys must be an object");
51+
}
52+
const sanitized: Record<string, Record<string, unknown> | undefined> = {};
53+
for (const fingerprint of Object.keys(ssh_keys)) {
54+
const key = (ssh_keys as Record<string, unknown>)[fingerprint];
55+
if (!key) {
56+
sanitized[fingerprint] = undefined;
57+
continue;
58+
}
59+
if (!is_object(key)) {
60+
throw Error("each key in ssh_keys must be an object");
61+
}
62+
for (const field of Object.keys(key)) {
63+
if (
64+
!["title", "value", "creation_date", "last_use_date"].includes(field)
65+
) {
66+
throw Error(`invalid ssh_keys field '${field}'`);
67+
}
68+
}
69+
sanitized[fingerprint] = key as Record<string, unknown>;
70+
}
71+
return sanitized;
72+
}
73+
74+
/**
75+
* Sanitize and security-check project user mutations submitted via user set query.
76+
*
77+
* Only permits modifying the requesting user's own entry (hide/upgrades/ssh_keys).
78+
* Collaborator role changes must use dedicated APIs that enforce ownership rules.
79+
*/
80+
export function sanitizeUserSetQueryProjectUsers(
81+
obj: { users?: unknown } | undefined,
82+
account_id: string,
83+
): Record<string, AllowedUserFields> | undefined {
84+
if (obj?.users == null) {
85+
return undefined;
86+
}
87+
assert_valid_account_id(account_id);
88+
if (!is_object(obj.users)) {
89+
throw Error("users must be an object");
90+
}
91+
92+
const sanitized: Record<string, AllowedUserFields> = {};
93+
const usersInput = obj.users as Record<string, unknown>;
94+
95+
for (const id of Object.keys(usersInput)) {
96+
if (!is_valid_uuid_string(id)) {
97+
throw Error(`invalid account_id '${id}'`);
98+
}
99+
if (id !== account_id) {
100+
throw Error("users set queries may only modify the requesting account");
101+
}
102+
const user = usersInput[id];
103+
if (!is_object(user)) {
104+
throw Error("user entry must be an object");
105+
}
106+
107+
ensureAllowedKeys(user as Record<string, unknown>);
108+
109+
const entry: AllowedUserFields = {};
110+
if ("hide" in user) {
111+
if (typeof (user as any).hide !== "boolean") {
112+
throw Error("invalid type for field 'hide'");
113+
}
114+
entry.hide = (user as any).hide;
115+
}
116+
if ("upgrades" in user) {
117+
entry.upgrades = sanitizeUpgrades((user as any).upgrades);
118+
}
119+
if ("ssh_keys" in user) {
120+
entry.ssh_keys = sanitizeSshKeys((user as any).ssh_keys);
121+
}
122+
sanitized[id] = entry;
123+
}
124+
125+
return sanitized;
126+
}

src/packages/database/postgres/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ export interface PostgreSQL extends EventEmitter {
161161
cb: CB;
162162
}): void;
163163

164+
_user_set_query_project_users(
165+
obj: any,
166+
account_id: string,
167+
): Record<string, unknown> | undefined;
168+
164169
user_is_in_project_group(opts: {
165170
account_id: string;
166171
project_id: string;

0 commit comments

Comments
 (0)