feat: folder shares apply to hosts added later (#1343)

* feat: folder shares apply to hosts added later

Sharing a folder only fanned grants out to the hosts in it at the time.
The share is now also kept as a standing rule on the folder, and a host
created in or moved into it (or a subfolder) inherits the same access
and secret snapshots. Rules follow folder renames and can be stopped
from the share dialog.

* fix: stabilize folder access migrations
This commit is contained in:
ZacharyZcR
2026-08-25 04:50:51 +08:00
committed by GitHub
parent 82143946c7
commit 302ac19e6c
24 changed files with 28903 additions and 20 deletions
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE `folder_access` (
`id` int AUTO_INCREMENT NOT NULL,
`owner_user_id` varchar(255) NOT NULL,
`folder` varchar(255) NOT NULL,
`user_id` varchar(255),
`role_id` int,
`granted_by` varchar(255) NOT NULL,
`permission_level` text NOT NULL DEFAULT ('connect'),
`expires_at` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `folder_access_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `ssh_data` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `snippets` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `ssh_credentials` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `vault_profiles` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -134,6 +134,13 @@
"when": 1787600445146,
"tag": "0018_fancy_barracuda",
"breakpoints": true
},
{
"idx": 19,
"version": "5",
"when": 1787602480861,
"tag": "0019_certain_archangel",
"breakpoints": true
}
]
}
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "folder_access" (
"id" serial PRIMARY KEY NOT NULL,
"owner_user_id" varchar(255) NOT NULL,
"folder" varchar(255) NOT NULL,
"user_id" varchar(255),
"role_id" integer,
"granted_by" varchar(255) NOT NULL,
"permission_level" text DEFAULT 'connect' NOT NULL,
"expires_at" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ssh_data" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "snippets" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "ssh_credentials" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "vault_profiles" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_folder_access_owner_folder" ON "folder_access" USING btree ("owner_user_id","folder");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -134,6 +134,13 @@
"when": 1787600442827,
"tag": "0018_puzzling_night_nurse",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1787602478567,
"tag": "0019_oval_puff_adder",
"breakpoints": true
}
]
}
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE `folder_access` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`owner_user_id` text NOT NULL,
`folder` text NOT NULL,
`user_id` text,
`role_id` integer,
`granted_by` text NOT NULL,
`permission_level` text DEFAULT 'connect' NOT NULL,
`expires_at` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -106,6 +106,13 @@
"when": 1787600440707,
"tag": "0014_lean_doctor_octopus",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1787602476319,
"tag": "0015_glossy_shotgun",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -171,8 +171,8 @@ function transform(source, dialect) {
);
const imports = isPg
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n foreignKey,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n foreignKey,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
out = out.replace(
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
+17
View File
@@ -662,6 +662,23 @@ async function initializeCompleteDatabase(): Promise<void> {
);
CREATE INDEX IF NOT EXISTS idx_shared_credential_secrets_target ON shared_credential_secrets (target_user_id, credential_id);
CREATE TABLE IF NOT EXISTS folder_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id TEXT NOT NULL,
folder TEXT NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'connect',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
+48 -7
View File
@@ -16,6 +16,7 @@ import {
double,
index,
uniqueIndex,
foreignKey,
type AnyMySqlColumn,
} from "drizzle-orm/mysql-core";
import { sql } from "drizzle-orm";
@@ -150,7 +151,7 @@ export const hosts = mysqlTable(
ip: text("ip").notNull(),
port: int("port").notNull(),
username: text("username").notNull(),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
// Sub-host nesting: a host acting as an organizational parent for other
// hosts, mutually exclusive with folder (see host route validation).
parentHostId: int("parent_host_id").references(
@@ -439,7 +440,7 @@ export const sshCredentials = mysqlTable(
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
pin: boolean("pin").notNull().default(false),
// Manual drag-to-reorder position within a folder. Null means the
@@ -505,7 +506,7 @@ export const snippets = mysqlTable(
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
order: int("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
@@ -1022,7 +1023,7 @@ export const vaultProfiles = mysqlTable("vault_profiles", {
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
@@ -2098,9 +2099,7 @@ export const sharedCredentialSecrets = mysqlTable(
"shared_credential_secrets",
{
id: int("id").autoincrement().primaryKey(),
credentialAccessId: int("credential_access_id")
.notNull()
.references(() => credentialAccess.id, { onDelete: "cascade" }),
credentialAccessId: int("credential_access_id").notNull(),
targetUserId: varchar("target_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -2125,6 +2124,11 @@ export const sharedCredentialSecrets = mysqlTable(
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
@@ -2135,3 +2139,40 @@ export const sharedCredentialSecrets = mysqlTable(
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = mysqlTable(
"folder_access",
{
id: int("id").autoincrement().primaryKey(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: varchar("folder", { length: 255 }).notNull(),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
+48 -7
View File
@@ -17,6 +17,7 @@ import {
doublePrecision,
index,
uniqueIndex,
foreignKey,
type AnyPgColumn,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
@@ -151,7 +152,7 @@ export const hosts = pgTable(
ip: text("ip").notNull(),
port: integer("port").notNull(),
username: text("username").notNull(),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
// Sub-host nesting: a host acting as an organizational parent for other
// hosts, mutually exclusive with folder (see host route validation).
parentHostId: integer("parent_host_id").references(
@@ -440,7 +441,7 @@ export const sshCredentials = pgTable(
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
pin: boolean("pin").notNull().default(false),
// Manual drag-to-reorder position within a folder. Null means the
@@ -506,7 +507,7 @@ export const snippets = pgTable(
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
order: integer("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
@@ -1023,7 +1024,7 @@ export const vaultProfiles = pgTable("vault_profiles", {
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
@@ -2099,9 +2100,7 @@ export const sharedCredentialSecrets = pgTable(
"shared_credential_secrets",
{
id: serial("id").primaryKey(),
credentialAccessId: integer("credential_access_id")
.notNull()
.references(() => credentialAccess.id, { onDelete: "cascade" }),
credentialAccessId: integer("credential_access_id").notNull(),
targetUserId: varchar("target_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -2126,6 +2125,11 @@ export const sharedCredentialSecrets = pgTable(
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
@@ -2136,3 +2140,40 @@ export const sharedCredentialSecrets = pgTable(
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = pgTable(
"folder_access",
{
id: serial("id").primaryKey(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: varchar("folder", { length: 255 }).notNull(),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
+44 -3
View File
@@ -5,6 +5,7 @@ import {
real,
index,
uniqueIndex,
foreignKey,
type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
@@ -2095,9 +2096,7 @@ export const sharedCredentialSecrets = sqliteTable(
"shared_credential_secrets",
{
id: integer("id").primaryKey({ autoIncrement: true }),
credentialAccessId: integer("credential_access_id")
.notNull()
.references(() => credentialAccess.id, { onDelete: "cascade" }),
credentialAccessId: integer("credential_access_id").notNull(),
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -2122,6 +2121,11 @@ export const sharedCredentialSecrets = sqliteTable(
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
@@ -2132,3 +2136,40 @@ export const sharedCredentialSecrets = sqliteTable(
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = sqliteTable(
"folder_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
ownerUserId: text("owner_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: text("folder").notNull(),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
@@ -42,6 +42,7 @@ import { CollabRoomRepository } from "./collab-room-repository.js";
import { SecretSourceRepository } from "./secret-source-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
@@ -510,6 +511,13 @@ export function createCurrentUserRepository(): UserRepository {
);
}
export function createCurrentFolderAccessRepository(): FolderAccessRepository {
return new FolderAccessRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("folder_access_repository_write"),
);
}
export function createCurrentCredentialAccessRepository(): CredentialAccessRepository {
return new CredentialAccessRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,190 @@
import { and, eq, inArray, like, or } from "drizzle-orm";
import { folderAccess, roles, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
export type FolderAccessRecord = typeof folderAccess.$inferSelect;
export interface FolderAccessListItem extends FolderAccessRecord {
targetType: "user" | "role";
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
}
export type FolderAccessTarget =
| { targetType: "user"; targetUserId: string }
| { targetType: "role"; targetRoleId: number };
/** "A / B / C" → ["A", "A / B", "A / B / C"]: a host inherits rules on every ancestor. */
export function folderAncestors(folder: string): string[] {
const parts = folder.split(" / ");
return parts.map((_, i) => parts.slice(0, i + 1).join(" / "));
}
export class FolderAccessRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async upsert(input: {
ownerUserId: string;
folder: string;
grantedBy: string;
permissionLevel: string;
expiresAt: string | null;
target: FolderAccessTarget;
}): Promise<FolderAccessRecord> {
const targetFilter =
input.target.targetType === "user"
? eq(folderAccess.userId, input.target.targetUserId)
: eq(folderAccess.roleId, input.target.targetRoleId);
const existing = await this.context.drizzle
.select()
.from(folderAccess)
.where(
and(
eq(folderAccess.ownerUserId, input.ownerUserId),
eq(folderAccess.folder, input.folder),
targetFilter,
),
)
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(folderAccess)
.set({
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
grantedBy: input.grantedBy,
})
.where(eq(folderAccess.id, existing[0].id));
await this.afterWrite();
return {
...existing[0],
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
};
}
const [created] = await insertReturning(this.context, folderAccess, {
ownerUserId: input.ownerUserId,
folder: input.folder,
userId:
input.target.targetType === "user" ? input.target.targetUserId : null,
roleId:
input.target.targetType === "role" ? input.target.targetRoleId : null,
grantedBy: input.grantedBy,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
});
await this.afterWrite();
return created;
}
/** Rules that apply to a host in this folder: the folder's own and its ancestors'. */
async listApplicable(
ownerUserId: string,
folder: string,
now = new Date().toISOString(),
): Promise<FolderAccessRecord[]> {
const rows = await this.context.drizzle
.select()
.from(folderAccess)
.where(
and(
eq(folderAccess.ownerUserId, ownerUserId),
inArray(folderAccess.folder, folderAncestors(folder)),
),
);
return rows.filter((row) => !row.expiresAt || row.expiresAt >= now);
}
async listForFolder(
ownerUserId: string,
folder: string,
): Promise<FolderAccessListItem[]> {
const rows = await this.context.drizzle
.select({
rule: folderAccess,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
})
.from(folderAccess)
.leftJoin(users, eq(folderAccess.userId, users.id))
.leftJoin(roles, eq(folderAccess.roleId, roles.id))
.where(
and(
eq(folderAccess.ownerUserId, ownerUserId),
eq(folderAccess.folder, folder),
),
);
return rows.map((row) => ({
...row.rule,
targetType: row.rule.roleId ? ("role" as const) : ("user" as const),
username: row.username,
roleName: row.roleName,
roleDisplayName: row.roleDisplayName,
}));
}
async findById(
id: number,
ownerUserId: string,
): Promise<FolderAccessRecord | null> {
const rows = await this.context.drizzle
.select()
.from(folderAccess)
.where(
and(eq(folderAccess.id, id), eq(folderAccess.ownerUserId, ownerUserId)),
)
.limit(1);
return rows[0] ?? null;
}
async deleteById(id: number, ownerUserId: string): Promise<void> {
await this.context.drizzle
.delete(folderAccess)
.where(
and(eq(folderAccess.id, id), eq(folderAccess.ownerUserId, ownerUserId)),
);
await this.afterWrite();
}
/** Follows a folder rename, including subfolders. */
async renameFolder(
ownerUserId: string,
oldName: string,
newName: string,
): Promise<void> {
const prefix = `${oldName} / `;
const rows = await this.context.drizzle
.select({ id: folderAccess.id, folder: folderAccess.folder })
.from(folderAccess)
.where(
and(
eq(folderAccess.ownerUserId, ownerUserId),
or(
eq(folderAccess.folder, oldName),
like(folderAccess.folder, `${prefix}%`),
),
),
);
for (const row of rows) {
const renamed =
row.folder === oldName
? newName
: `${newName} / ${row.folder.slice(prefix.length)}`;
await this.context.drizzle
.update(folderAccess)
.set({ folder: renamed })
.where(eq(folderAccess.id, row.id));
}
if (rows.length) await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -6,6 +6,7 @@ import {
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
@@ -92,6 +93,11 @@ export function registerHostFolderRoutes(
oldName,
newName,
);
await createCurrentFolderAccessRepository().renameFolder(
userId,
oldName,
newName,
);
res.json({
message: "Folder renamed successfully",
+30
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
@@ -503,6 +504,20 @@ router.post(
}
const createdHost = result;
// Standing folder shares apply to the newcomer.
try {
await applyFolderAccessRules(
createdHost.id,
userId!,
createdHost.folder,
);
} catch (folderAccessError) {
sshLogger.warn("Failed to inherit folder access on host create", {
operation: "host_create_folder_access",
hostId: createdHost.id,
error: getErrorMessage(folderAccessError),
});
}
const baseHost = transformHostResponse(createdHost);
const resolvedHost =
@@ -1372,6 +1387,21 @@ router.put(
sshDataObj,
);
// A host that moved into a folder inherits that folder's standing shares.
try {
await applyFolderAccessRules(
Number(hostId),
ownerId,
sshDataObj.folder as string | null | undefined,
);
} catch (folderAccessError) {
sshLogger.warn("Failed to inherit folder access on host update", {
operation: "host_update_folder_access",
hostId: parseInt(hostId),
error: getErrorMessage(folderAccessError),
});
}
// Keep every recipient's re-encrypted secret snapshots in sync with
// the updated host record.
try {
+89
View File
@@ -24,6 +24,7 @@ import {
} from "../../utils/permission-catalog.js";
import {
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentCredentialRepository,
createCurrentCredentialAccessRepository,
createCurrentHostResolutionRepository,
@@ -430,6 +431,22 @@ router.post(
);
const expiresAt = expiryFromDuration(durationHours);
// Remember the share on the folder itself so hosts added later inherit it.
const folderAccessRepository = createCurrentFolderAccessRepository();
for (const target of targets) {
await folderAccessRepository.upsert({
ownerUserId: userId,
folder,
grantedBy: userId,
permissionLevel,
expiresAt,
target:
target.type === "user"
? { targetType: "user", targetUserId: target.id as string }
: { targetType: "role", targetRoleId: target.id as number },
});
}
const rbacAccessRepository = createCurrentRbacAccessRepository();
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
@@ -583,6 +600,78 @@ router.post(
* 500:
* description: Failed to update grant.
*/
/**
* @openapi
* /rbac/folder/access:
* get:
* summary: Standing shares on one of your folders (inherited by hosts added later)
* tags:
* - RBAC
* parameters:
* - in: query
* name: folder
* required: true
* schema:
* type: string
*/
router.get(
"/folder/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const folder = String(req.query.folder ?? "");
if (!isNonEmptyString(folder)) {
return res.status(400).json({ error: "folder is required" });
}
try {
res.json({
rules: await createCurrentFolderAccessRepository().listForFolder(
req.userId!,
folder,
),
});
} catch (error) {
databaseLogger.error("Failed to list folder access rules", error, {
operation: "list_folder_access",
});
res.status(500).json({ error: "Failed to list folder access" });
}
},
);
/**
* @openapi
* /rbac/folder/access/{id}:
* delete:
* summary: Stop a folder share from applying to hosts added later
* description: Grants already fanned out to hosts stay; revoke those per host.
* tags:
* - RBAC
*/
router.delete(
"/folder/access/:id",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const ruleId = parseInt(String(req.params.id), 10);
if (isNaN(ruleId)) {
return res.status(400).json({ error: "Invalid rule ID" });
}
try {
const repository = createCurrentFolderAccessRepository();
if (!(await repository.findById(ruleId, req.userId!))) {
return res.status(404).json({ error: "Rule not found" });
}
await repository.deleteById(ruleId, req.userId!);
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to delete folder access rule", error, {
operation: "delete_folder_access",
ruleId,
});
res.status(500).json({ error: "Failed to delete folder access" });
}
},
);
router.patch(
"/host/:id/access/:accessId",
authenticateJWT,
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rules: [] as Array<Record<string, unknown>>,
grants: [] as unknown[],
snapshots: [] as unknown[],
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentFolderAccessRepository: () => ({
listApplicable: async () => state.rules,
}),
createCurrentRbacAccessRepository: () => ({
upsertHostAccess: async (input: unknown) => {
state.grants.push(input);
return { id: state.grants.length, created: true };
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
snapshotForUser: async (...args: unknown[]) => {
state.snapshots.push(["user", ...args]);
},
snapshotForRole: async (...args: unknown[]) => {
state.snapshots.push(["role", ...args]);
},
}),
},
}));
const { applyFolderAccessRules } =
await import("../../utils/folder-access-inheritance.js");
const { folderAncestors } =
await import("../../database/repositories/folder-access-repository.js");
describe("folder access inheritance", () => {
beforeEach(() => {
state.rules = [];
state.grants = [];
state.snapshots = [];
});
it("lists a folder and every ancestor, so subfolders inherit", () => {
expect(folderAncestors("Prod / EU / Web")).toEqual([
"Prod",
"Prod / EU",
"Prod / EU / Web",
]);
});
it("fans standing rules out to a host and snapshots secrets for each target", async () => {
state.rules = [
{
id: 1,
userId: "alice",
roleId: null,
grantedBy: "owner",
permissionLevel: "connect",
expiresAt: null,
},
{
id: 2,
userId: null,
roleId: 7,
grantedBy: "owner",
permissionLevel: "view",
expiresAt: "2030-01-01",
},
];
const applied = await applyFolderAccessRules(42, "owner", "Prod");
expect(applied).toBe(2);
expect(state.grants).toEqual([
expect.objectContaining({
hostId: 42,
targetType: "user",
targetUserId: "alice",
permissionLevel: "connect",
}),
expect.objectContaining({
hostId: 42,
targetType: "role",
targetRoleId: 7,
permissionLevel: "view",
expiresAt: "2030-01-01",
}),
]);
expect(state.snapshots).toEqual([
["user", 1, 42, "alice", "owner"],
["role", 2, 42, 7, "owner"],
]);
});
it("does nothing for hosts outside any folder", async () => {
state.rules = [
{
id: 1,
userId: "alice",
roleId: null,
grantedBy: "o",
permissionLevel: "connect",
expiresAt: null,
},
];
expect(await applyFolderAccessRules(1, "owner", null)).toBe(0);
expect(state.grants).toEqual([]);
});
});
@@ -0,0 +1,55 @@
import {
createCurrentFolderAccessRepository,
createCurrentRbacAccessRepository,
} from "../database/repositories/factory.js";
import { databaseLogger } from "./logger.js";
import { SharedHostSecretsManager } from "./shared-host-secrets-manager.js";
/**
* Gives a host every standing share on its folder (and ancestor folders):
* called when a host is created in, or moved into, a folder. Grants already
* present on the host are updated to the rule's level, like re-sharing.
*/
export async function applyFolderAccessRules(
hostId: number,
ownerId: string,
folder: string | null | undefined,
): Promise<number> {
if (!folder) return 0;
const rules = await createCurrentFolderAccessRepository().listApplicable(
ownerId,
folder,
);
if (rules.length === 0) return 0;
const accessRepository = createCurrentRbacAccessRepository();
const secrets = SharedHostSecretsManager.getInstance();
let applied = 0;
for (const rule of rules) {
try {
const grant = await accessRepository.upsertHostAccess({
hostId,
grantedBy: rule.grantedBy,
permissionLevel: rule.permissionLevel,
expiresAt: rule.expiresAt,
...(rule.userId
? { targetType: "user" as const, targetUserId: rule.userId }
: { targetType: "role" as const, targetRoleId: rule.roleId! }),
});
if (rule.userId) {
await secrets.snapshotForUser(grant.id, hostId, rule.userId, ownerId);
} else if (rule.roleId) {
await secrets.snapshotForRole(grant.id, hostId, rule.roleId, ownerId);
}
applied++;
} catch (error) {
databaseLogger.warn("Failed to apply folder access rule to host", {
operation: "folder_access_apply",
hostId,
ruleId: rule.id,
error,
});
}
}
return applied;
}
+36
View File
@@ -191,6 +191,42 @@ export async function shareFolder(
}
}
export interface FolderAccessRule {
id: number;
folder: string;
targetType: "user" | "role";
userId: string | null;
roleId: number | null;
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
permissionLevel: SharePermissionLevel;
expiresAt: string | null;
createdAt: string;
}
/** Standing shares on a folder - what hosts added to it later inherit. */
export async function getFolderAccess(
folder: string,
): Promise<{ rules: FolderAccessRule[] }> {
try {
const response = await rbacApi.get("/rbac/folder/access", {
params: { folder },
});
return response.data;
} catch (error) {
throw handleApiError(error, "get folder access");
}
}
export async function revokeFolderAccess(ruleId: number): Promise<void> {
try {
await rbacApi.delete(`/rbac/folder/access/${ruleId}`);
} catch (error) {
throw handleApiError(error, "revoke folder access");
}
}
export async function updateHostAccess(
hostId: number,
accessId: number,
+3
View File
@@ -1611,6 +1611,9 @@
"shareWithCount": "Share ({{count}})",
"currentAccess": "Current access",
"noAccessEntries": "This host has not been shared yet",
"folderRulesTitle": "Applies to hosts added later",
"folderRulesHint": "These shares stay on the folder: a host created in or moved into it (or a subfolder) gets the same access automatically. Removing a rule only stops future inheritance.",
"folderRuleRemove": "Stop inheriting",
"folderShareSummary": "Shared {{shared}} of {{total}} host(s) in this folder",
"grantedBy": "Granted by",
"expires": "Expires",
+56 -1
View File
@@ -1,4 +1,9 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import {
getFolderAccess,
revokeFolderAccess,
type FolderAccessRule,
} from "@/api/rbac-api";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
@@ -177,6 +182,19 @@ export function HostShareModal({
setAccessList(res.accessList ?? []);
}
const [folderRules, setFolderRules] = useState<FolderAccessRule[]>([]);
const refreshFolderRules = useCallback(async () => {
if (!isFolderShare || !folder) return;
try {
setFolderRules((await getFolderAccess(folder)).rules);
} catch {
setFolderRules([]);
}
}, [isFolderShare, folder]);
useEffect(() => {
if (open) void refreshFolderRules();
}, [open, refreshFolderRules]);
async function handleShare() {
if ((!host && !folder) || selectedCount === 0) return;
const targets: ShareTarget[] = [
@@ -196,6 +214,7 @@ export function HostShareModal({
permissionLevel,
...(durationHours ? { durationHours } : {}),
});
await refreshFolderRules();
setFolderShareSummary({
hostsShared: result.hostsShared,
hostsTotal: result.hostsTotal,
@@ -499,6 +518,42 @@ export function HostShareModal({
</div>
)}
{isFolderShare && folderRules.length > 0 && (
<div className="flex flex-col gap-1 px-3 pb-2 shrink-0">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sharing.folderRulesTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("hosts.sharing.folderRulesHint")}
</span>
{folderRules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 text-xs px-1 py-0.5"
>
<span className="flex-1 truncate">
{rule.targetType === "role"
? rule.roleDisplayName || rule.roleName
: rule.username}
</span>
<span className="text-[10px] uppercase text-muted-foreground">
{rule.permissionLevel}
</span>
<button
type="button"
className="text-[10px] text-muted-foreground hover:text-destructive"
title={t("hosts.sharing.folderRuleRemove")}
onClick={() => {
void revokeFolderAccess(rule.id).then(refreshFolderRules);
}}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
)}
{/* Current access: takes remaining space, scrolls independently */}
{!isFolderShare && (
<div className="flex flex-col flex-1 min-h-0">