Merge pull request #3708 from HeyPuter/juancastro/put-1701-13-add-audit_team_membership-and-shareholder_group_id
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

🏗️ PUT-1701: Add audit_team_membership and share.holder_group_id
This commit is contained in:
Juan Fernando Castro
2026-09-03 15:17:34 -04:00
committed by GitHub
5 changed files with 327 additions and 1 deletions
@@ -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`);