mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-10 15:25:49 +00:00
Merge pull request #3708 from HeyPuter/juancastro/put-1701-13-add-audit_team_membership-and-shareholder_group_id
🏗️ PUT-1701: Add audit_team_membership and share.holder_group_id
This commit is contained in:
@@ -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 = 74;
|
||||
const CURRENT_SCHEMA_VERSION = 75;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -191,6 +191,124 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
await expect(insert()).rejects.toThrow(/UNIQUE/iu);
|
||||
});
|
||||
|
||||
it('applies the audit table and group-share columns from 0079', async () => {
|
||||
const columns = (await client.read(
|
||||
"SELECT name FROM pragma_table_info('audit_team_membership')",
|
||||
)) as { name: string }[];
|
||||
expect(columns.map((r) => r.name)).toEqual([
|
||||
'id',
|
||||
'group_id',
|
||||
'group_id_keep',
|
||||
'user_id',
|
||||
'user_id_keep',
|
||||
'actor_user_id',
|
||||
'action',
|
||||
'reason',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
const shareColumns = (await client.read(
|
||||
"SELECT name FROM pragma_table_info('share')",
|
||||
)) as { name: string }[];
|
||||
expect(shareColumns.map((r) => r.name)).toContain('holder_group_id');
|
||||
|
||||
const indexes = (await client.read(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' " +
|
||||
'AND (name LIKE ? OR name LIKE ?)',
|
||||
['idx_audit_team_membership%', 'idx_share_holder_group%'],
|
||||
)) as { name: string }[];
|
||||
expect(indexes.map((r) => r.name).sort()).toEqual([
|
||||
// The `_fk` three stop a user or group delete scanning the audit table.
|
||||
'idx_audit_team_membership_actor_fk',
|
||||
'idx_audit_team_membership_group',
|
||||
'idx_audit_team_membership_group_fk',
|
||||
'idx_audit_team_membership_user',
|
||||
'idx_audit_team_membership_user_fk',
|
||||
'idx_share_holder_group',
|
||||
'idx_share_holder_group_entry_issuer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an audit row after its group is deleted, blanking only the FK', async () => {
|
||||
// 0043 leaves `PRAGMA foreign_keys = ON`, so this is real behaviour.
|
||||
const [group] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `group`',
|
||||
)) as { id: number }[];
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
|
||||
await client.write(
|
||||
'INSERT INTO `audit_team_membership` ' +
|
||||
'(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' +
|
||||
'`actor_user_id`, `action`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[group.id, group.id, user.id, user.id, user.id, 'reset_member_password'],
|
||||
);
|
||||
|
||||
await client.write('DELETE FROM `group` WHERE `id` = ?', [group.id]);
|
||||
|
||||
await expect(
|
||||
client.read(
|
||||
'SELECT `group_id`, `group_id_keep`, `action` FROM `audit_team_membership`',
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
group_id: null,
|
||||
group_id_keep: group.id,
|
||||
action: 'reset_member_password',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('constrains team shares that the user-holder index cannot', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
const groups = (await client.read(
|
||||
'SELECT `id` FROM `group` ORDER BY `id` LIMIT 2',
|
||||
)) as { id: number }[];
|
||||
|
||||
await client.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
['11111111-1111-4111-8111-111111111111', 'shared.txt', user.id, 0],
|
||||
);
|
||||
const [entry] = (await client.read(
|
||||
'SELECT `id` FROM `fsentries` WHERE `uuid` = ?',
|
||||
['11111111-1111-4111-8111-111111111111'],
|
||||
)) as { id: number }[];
|
||||
|
||||
const insertShare = (uid: string, groupId: number) =>
|
||||
client.write(
|
||||
'INSERT INTO `share` ' +
|
||||
'(`uid`, `issuer_user_id`, `recipient_email`, `holder_user_id`, ' +
|
||||
'`holder_group_id`, `fsentry_id`) VALUES (?, ?, ?, NULL, ?, ?)',
|
||||
[uid, user.id, 'team@test.local', groupId, entry.id],
|
||||
);
|
||||
|
||||
await insertShare('share-team-a', groups[0].id);
|
||||
// Different group, same file and issuer -- allowed.
|
||||
await insertShare('share-team-b', groups[1].id);
|
||||
// Both have `holder_user_id` NULL, so the user-holder index permitted them.
|
||||
await expect(insertShare('share-team-c', groups[0].id)).rejects.toThrow(
|
||||
/UNIQUE/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches a handle case-insensitively on lookup, not just on insert', async () => {
|
||||
// Index-only NOCASE would leave `WHERE handle = ?` case-sensitive here while
|
||||
// mysql's utf8mb4_unicode_ci column matched -- one query, two behaviours.
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['design-team'],
|
||||
);
|
||||
await expect(
|
||||
client.read('SELECT `handle` FROM `group` WHERE `handle` = ?', [
|
||||
'Design-Team',
|
||||
]),
|
||||
).resolves.toEqual([{ handle: 'design-team' }]);
|
||||
});
|
||||
|
||||
it('runs the javascript migrations, not just the .sql ones', async () => {
|
||||
// The `system` user only exists because 0025 (a .dbmig.js file) ran
|
||||
// inside the migration VM.
|
||||
|
||||
@@ -108,6 +108,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[71, ['0076_event-handlers.sql']],
|
||||
[72, ['0077_teams.sql']],
|
||||
[73, ['0078_jct-user-group-pair-unique.sql']],
|
||||
[74, ['0079_team-audit-and-group-shares.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0076. Types follow mysql_mig_1; the guarded procedure follows mysql_mig_22.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `audit_team_membership` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int unsigned DEFAULT NULL,
|
||||
`group_id_keep` int unsigned NOT NULL,
|
||||
`user_id` int unsigned DEFAULT NULL,
|
||||
`user_id_keep` int unsigned NOT NULL,
|
||||
`actor_user_id` int unsigned DEFAULT NULL,
|
||||
`action` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_audit_team_membership_group` (`group_id_keep`, `id`),
|
||||
KEY `idx_audit_team_membership_user` (`user_id_keep`, `id`),
|
||||
KEY `idx_audit_team_membership_group_fk` (`group_id`),
|
||||
KEY `idx_audit_team_membership_user_fk` (`user_id`),
|
||||
KEY `idx_audit_team_membership_actor_fk` (`actor_user_id`),
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
CONSTRAINT `fk_audit_team_membership_group` FOREIGN KEY (`group_id`)
|
||||
REFERENCES `group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_audit_team_membership_user` FOREIGN KEY (`user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_audit_team_membership_actor` FOREIGN KEY (`actor_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CALL _puter_add_col('share', 'holder_group_id', '`holder_group_id` int unsigned DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_group_share_constraints;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_group_share_constraints()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_holder_group'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD INDEX `idx_share_holder_group` (`holder_group_id`, `id`);
|
||||
END IF;
|
||||
-- Team shares leave `holder_user_id` NULL, so the existing unique index binds none.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_holder_group_entry_issuer'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_holder_group_entry_issuer`
|
||||
(`holder_group_id`, `fsentry_id`, `issuer_user_id`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND CONSTRAINT_NAME = 'share_holder_group_fk'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD CONSTRAINT `share_holder_group_fk`
|
||||
FOREIGN KEY (`holder_group_id`) REFERENCES `group` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_group_share_constraints();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_group_share_constraints;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0076. Types follow `audit_user_to_group_permissions` in postgres_mig_1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_team_membership (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
group_id integer REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
group_id_keep integer NOT NULL,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
actor_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
|
||||
action varchar(255) NOT NULL,
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_group
|
||||
ON audit_team_membership (group_id_keep, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_user
|
||||
ON audit_team_membership (user_id_keep, id);
|
||||
|
||||
-- SET NULL has to find the child rows; postgres does not index FK columns for you.
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_group_fk
|
||||
ON audit_team_membership (group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_user_fk
|
||||
ON audit_team_membership (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_actor_fk
|
||||
ON audit_team_membership (actor_user_id);
|
||||
|
||||
ALTER TABLE share ADD COLUMN IF NOT EXISTS holder_group_id integer
|
||||
REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_holder_group
|
||||
ON share (holder_group_id, id);
|
||||
|
||||
-- Default NULLS DISTINCT, matching the other engines, so this binds only team shares.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_holder_group_entry_issuer
|
||||
ON share (holder_group_id, fsentry_id, issuer_user_id);
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Insert-only record of what an admin did to an account, plus `share.holder_group_id`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `audit_team_membership` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Nullable FK beside a NOT NULL `_keep`, as `audit_user_to_group_permissions` since 0019.
|
||||
"group_id" INTEGER DEFAULT NULL,
|
||||
"group_id_keep" INTEGER NOT NULL,
|
||||
"user_id" INTEGER DEFAULT NULL,
|
||||
"user_id_keep" INTEGER NOT NULL,
|
||||
"actor_user_id" INTEGER DEFAULT NULL,
|
||||
|
||||
"action" TEXT NOT NULL,
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
FOREIGN KEY("group_id") REFERENCES "group" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
FOREIGN KEY("actor_user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- The admin's view: one workspace, newest first.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group`
|
||||
ON `audit_team_membership` (`group_id_keep`, `id`);
|
||||
|
||||
-- The member's view; the only place a reset becomes visible to its subject.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_user`
|
||||
ON `audit_team_membership` (`user_id_keep`, `id`);
|
||||
|
||||
-- Without these, SET NULL scans the audit table on every user or group delete.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group_fk`
|
||||
ON `audit_team_membership` (`group_id`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_user_fk`
|
||||
ON `audit_team_membership` (`user_id`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_actor_fk`
|
||||
ON `audit_team_membership` (`actor_user_id`);
|
||||
|
||||
-- Mirrors `holder_user_id` from 0067: a `share` row is a listing entry, not the grant.
|
||||
ALTER TABLE `share` ADD COLUMN `holder_group_id` INTEGER DEFAULT NULL
|
||||
REFERENCES `group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_share_holder_group`
|
||||
ON `share` (`holder_group_id`, `id`);
|
||||
|
||||
-- Team shares leave `holder_user_id` NULL, and NULLs are distinct, so the existing
|
||||
-- unique index binds none of them.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_holder_group_entry_issuer`
|
||||
ON `share` (`holder_group_id`, `fsentry_id`, `issuer_user_id`);
|
||||
Reference in New Issue
Block a user