mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
chore(perms): drop the three by-hand groups no code reads
freeai, experimental and dangerous exist in prod but in no migration — they were added by hand when hardcoded permissions were keyed by group name. That map is now a flat per-user floor (`default_user_permissions`), so a group nothing looks up grants nothing. Guarded rather than unconditional, because both tables the delete can reach cascade: dropping a group that still carries permissions or members would silently revoke them from every member. Only a group with neither goes. One that survives has dependents and needs a deliberate decision — query user_to_group_permissions by group_id to see what it holds. system, admin, user and temp are untouched: config names two of them and code names the others. Matches on `extra.name`, not `metadata.name` — `metadata` carries the display title and colour, and `critical: true` is set on all of these including freeai, so it does not discriminate.
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 65;
|
||||
const CURRENT_SCHEMA_VERSION = 66;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -91,6 +91,105 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
expect(rows).toEqual([{ username: 'system' }]);
|
||||
});
|
||||
|
||||
// 0070 sweeps three by-hand groups no code reads. Both tables it can reach
|
||||
// cascade on delete, so the guard is the point: a group that still carries
|
||||
// permissions or members has to survive, or the sweep silently revokes
|
||||
// them. Re-running the statement is safe — it is a plain conditional DELETE.
|
||||
describe('0070 drop-orphaned-default-groups', () => {
|
||||
const MIGRATION = readFileSync(
|
||||
new URL(
|
||||
'./migrations/sqlite/0070_drop-orphaned-default-groups.sql',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const seedGroup = async (name: string): Promise<number> => {
|
||||
const [system] = await client.read(
|
||||
'SELECT `id` FROM `user` WHERE `uuid` = ?',
|
||||
[SYSTEM_USER_UUID],
|
||||
);
|
||||
const uid = `grp-${name}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await client.write(
|
||||
'INSERT INTO `group` (`uid`, `owner_user_id`, `extra`, `metadata`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
[uid, system.id, JSON.stringify({ name }), '{}'],
|
||||
);
|
||||
const [row] = await client.read(
|
||||
'SELECT `id` FROM `group` WHERE `uid` = ?',
|
||||
[uid],
|
||||
);
|
||||
return Number(row.id);
|
||||
};
|
||||
|
||||
const stillThere = async (id: number): Promise<boolean> => {
|
||||
const rows = await client.read(
|
||||
'SELECT `id` FROM `group` WHERE `id` = ?',
|
||||
[id],
|
||||
);
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
it('drops an orphaned freeai/experimental/dangerous group', async () => {
|
||||
const ids = await Promise.all(
|
||||
['freeai', 'experimental', 'dangerous'].map(seedGroup),
|
||||
);
|
||||
await client.write(MIGRATION);
|
||||
for (const id of ids) expect(await stillThere(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('spares one that still carries a permission row', async () => {
|
||||
const id = await seedGroup('freeai');
|
||||
const [system] = await client.read(
|
||||
'SELECT `id` FROM `user` WHERE `uuid` = ?',
|
||||
[SYSTEM_USER_UUID],
|
||||
);
|
||||
await client.write(
|
||||
'INSERT INTO `user_to_group_permissions` ' +
|
||||
'(`user_id`, `group_id`, `permission`, `extra`) VALUES (?, ?, ?, ?)',
|
||||
[system.id, id, 'service:some-paid-thing', '{}'],
|
||||
);
|
||||
|
||||
await client.write(MIGRATION);
|
||||
|
||||
expect(await stillThere(id)).toBe(true);
|
||||
// And the grant it carries is still intact, not cascaded away.
|
||||
const perms = await client.read(
|
||||
'SELECT `permission` FROM `user_to_group_permissions` WHERE `group_id` = ?',
|
||||
[id],
|
||||
);
|
||||
expect(perms).toEqual([{ permission: 'service:some-paid-thing' }]);
|
||||
});
|
||||
|
||||
it('spares one that still has members', async () => {
|
||||
const id = await seedGroup('experimental');
|
||||
const [system] = await client.read(
|
||||
'SELECT `id` FROM `user` WHERE `uuid` = ?',
|
||||
[SYSTEM_USER_UUID],
|
||||
);
|
||||
await client.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
|
||||
[system.id, id],
|
||||
);
|
||||
|
||||
await client.write(MIGRATION);
|
||||
|
||||
expect(await stillThere(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the groups the platform actually depends on alone', async () => {
|
||||
// system, admin, user and temp are named by config or code.
|
||||
await client.write(MIGRATION);
|
||||
const rows = await client.read(
|
||||
"SELECT json_extract(`extra`, '$.name') AS name FROM `group`",
|
||||
);
|
||||
const names = rows.map((r) => String(r.name));
|
||||
for (const name of ['system', 'admin', 'user']) {
|
||||
expect(names).toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an already-migrated database untouched on a second boot', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
|
||||
const path = join(dir, 'nested', 'puter.sqlite');
|
||||
|
||||
@@ -99,6 +99,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[62, ['0067_share_entries.sql']],
|
||||
[63, ['0068_referral-code-unique.sql']],
|
||||
[64, ['0069_user-block.sql']],
|
||||
[65, ['0070_drop-orphaned-default-groups.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
|
||||
-- `dangerous`. No migration creates them — they were added by hand back when
|
||||
-- hardcoded permissions were keyed by group name. That map is now a flat
|
||||
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
|
||||
-- so a group nothing looks up grants nothing, and the rows are dead weight.
|
||||
--
|
||||
-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and
|
||||
-- `jct_user_group.group_id` both cascade on delete, so removing a group that
|
||||
-- still carries permissions or members would silently revoke them from every
|
||||
-- member. Only a group with neither is dropped. A group that survives this
|
||||
-- migration has dependents and needs a deliberate decision, not a sweep --
|
||||
-- query `user_to_group_permissions` by `group_id` to see what it holds.
|
||||
--
|
||||
-- The joins rather than `NOT IN` subqueries: MySQL will not read from the table
|
||||
-- it is deleting from inside a subquery on the same statement.
|
||||
DELETE g FROM `group` g
|
||||
LEFT JOIN `user_to_group_permissions` p ON p.`group_id` = g.`id`
|
||||
LEFT JOIN `jct_user_group` j ON j.`group_id` = g.`id`
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(g.`extra`, '$.name'))
|
||||
IN ('freeai', 'experimental', 'dangerous')
|
||||
AND p.`group_id` IS NULL
|
||||
AND j.`group_id` IS NULL;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
|
||||
-- `dangerous`. No migration creates them — they were added by hand back when
|
||||
-- hardcoded permissions were keyed by group name. That map is now a flat
|
||||
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
|
||||
-- so a group nothing looks up grants nothing, and the rows are dead weight.
|
||||
--
|
||||
-- Guarded, and deliberately so: user_to_group_permissions.group_id and
|
||||
-- jct_user_group.group_id both cascade on delete, so removing a group that
|
||||
-- still carries permissions or members would silently revoke them from every
|
||||
-- member. Only a group with neither is dropped. A group that survives this
|
||||
-- migration has dependents and needs a deliberate decision, not a sweep --
|
||||
-- query user_to_group_permissions by group_id to see what it holds.
|
||||
DELETE FROM "group" g
|
||||
WHERE g.extra ->> 'name' IN ('freeai', 'experimental', 'dangerous')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_to_group_permissions p WHERE p.group_id = g.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jct_user_group j WHERE j.group_id = g.id
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
|
||||
-- `dangerous`. No migration creates them — they were added by hand back when
|
||||
-- hardcoded permissions were keyed by group name. That map is now a flat
|
||||
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
|
||||
-- so a group nothing looks up grants nothing, and the rows are dead weight.
|
||||
--
|
||||
-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and
|
||||
-- `jct_user_group.group_id` both cascade on delete, so removing a group that
|
||||
-- still carries permissions or members would silently revoke them from every
|
||||
-- member. Only a group with neither is dropped. A group that survives this
|
||||
-- migration has dependents and needs a deliberate decision, not a sweep --
|
||||
-- query `user_to_group_permissions` by `group_id` to see what it holds.
|
||||
DELETE FROM `group`
|
||||
WHERE json_extract(`extra`, '$.name') IN ('freeai', 'experimental', 'dangerous')
|
||||
AND `id` NOT IN (SELECT `group_id` FROM `user_to_group_permissions`)
|
||||
AND `id` NOT IN (SELECT `group_id` FROM `jct_user_group`);
|
||||
Reference in New Issue
Block a user