mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
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:
@@ -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
@@ -134,6 +134,13 @@
|
|||||||
"when": 1787600445146,
|
"when": 1787600445146,
|
||||||
"tag": "0018_fancy_barracuda",
|
"tag": "0018_fancy_barracuda",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1787602480861,
|
||||||
|
"tag": "0019_certain_archangel",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -134,6 +134,13 @@
|
|||||||
"when": 1787600442827,
|
"when": 1787600442827,
|
||||||
"tag": "0018_puzzling_night_nurse",
|
"tag": "0018_puzzling_night_nurse",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787602478567,
|
||||||
|
"tag": "0019_oval_puff_adder",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -106,6 +106,13 @@
|
|||||||
"when": 1787600440707,
|
"when": 1787600440707,
|
||||||
"tag": "0014_lean_doctor_octopus",
|
"tag": "0014_lean_doctor_octopus",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787602476319,
|
||||||
|
"tag": "0015_glossy_shotgun",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -171,8 +171,8 @@ function transform(source, dialect) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const imports = isPg
|
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 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 type AnyMySqlColumn,\n} from "drizzle-orm/mysql-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(
|
out = out.replace(
|
||||||
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
|
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
|
||||||
|
|||||||
@@ -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 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 (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
double,
|
double,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
foreignKey,
|
||||||
type AnyMySqlColumn,
|
type AnyMySqlColumn,
|
||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
@@ -150,7 +151,7 @@ export const hosts = mysqlTable(
|
|||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
port: int("port").notNull(),
|
port: int("port").notNull(),
|
||||||
username: text("username").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
|
// Sub-host nesting: a host acting as an organizational parent for other
|
||||||
// hosts, mutually exclusive with folder (see host route validation).
|
// hosts, mutually exclusive with folder (see host route validation).
|
||||||
parentHostId: int("parent_host_id").references(
|
parentHostId: int("parent_host_id").references(
|
||||||
@@ -439,7 +440,7 @@ export const sshCredentials = mysqlTable(
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
pin: boolean("pin").notNull().default(false),
|
pin: boolean("pin").notNull().default(false),
|
||||||
// Manual drag-to-reorder position within a folder. Null means the
|
// 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(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
order: int("order").notNull().default(0),
|
order: int("order").notNull().default(0),
|
||||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||||
createdAt: varchar("created_at", { length: 255 })
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
@@ -1022,7 +1023,7 @@ export const vaultProfiles = mysqlTable("vault_profiles", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
// Vault server connection (non-secret)
|
// Vault server connection (non-secret)
|
||||||
vaultAddr: text("vault_addr").notNull(),
|
vaultAddr: text("vault_addr").notNull(),
|
||||||
@@ -2098,9 +2099,7 @@ export const sharedCredentialSecrets = mysqlTable(
|
|||||||
"shared_credential_secrets",
|
"shared_credential_secrets",
|
||||||
{
|
{
|
||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
credentialAccessId: int("credential_access_id")
|
credentialAccessId: int("credential_access_id").notNull(),
|
||||||
.notNull()
|
|
||||||
.references(() => credentialAccess.id, { onDelete: "cascade" }),
|
|
||||||
targetUserId: varchar("target_user_id", { length: 255 })
|
targetUserId: varchar("target_user_id", { length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -2125,6 +2124,11 @@ export const sharedCredentialSecrets = mysqlTable(
|
|||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.credentialAccessId],
|
||||||
|
foreignColumns: [credentialAccess.id],
|
||||||
|
name: "shared_cred_secrets_access_id_fk",
|
||||||
|
}).onDelete("cascade"),
|
||||||
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
table.credentialAccessId,
|
table.credentialAccessId,
|
||||||
table.targetUserId,
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
doublePrecision,
|
doublePrecision,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
foreignKey,
|
||||||
type AnyPgColumn,
|
type AnyPgColumn,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
@@ -151,7 +152,7 @@ export const hosts = pgTable(
|
|||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
username: text("username").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
|
// Sub-host nesting: a host acting as an organizational parent for other
|
||||||
// hosts, mutually exclusive with folder (see host route validation).
|
// hosts, mutually exclusive with folder (see host route validation).
|
||||||
parentHostId: integer("parent_host_id").references(
|
parentHostId: integer("parent_host_id").references(
|
||||||
@@ -440,7 +441,7 @@ export const sshCredentials = pgTable(
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
pin: boolean("pin").notNull().default(false),
|
pin: boolean("pin").notNull().default(false),
|
||||||
// Manual drag-to-reorder position within a folder. Null means the
|
// 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(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
order: integer("order").notNull().default(0),
|
order: integer("order").notNull().default(0),
|
||||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||||
createdAt: varchar("created_at", { length: 255 })
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
@@ -1023,7 +1024,7 @@ export const vaultProfiles = pgTable("vault_profiles", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
// Vault server connection (non-secret)
|
// Vault server connection (non-secret)
|
||||||
vaultAddr: text("vault_addr").notNull(),
|
vaultAddr: text("vault_addr").notNull(),
|
||||||
@@ -2099,9 +2100,7 @@ export const sharedCredentialSecrets = pgTable(
|
|||||||
"shared_credential_secrets",
|
"shared_credential_secrets",
|
||||||
{
|
{
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
credentialAccessId: integer("credential_access_id")
|
credentialAccessId: integer("credential_access_id").notNull(),
|
||||||
.notNull()
|
|
||||||
.references(() => credentialAccess.id, { onDelete: "cascade" }),
|
|
||||||
targetUserId: varchar("target_user_id", { length: 255 })
|
targetUserId: varchar("target_user_id", { length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -2126,6 +2125,11 @@ export const sharedCredentialSecrets = pgTable(
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.credentialAccessId],
|
||||||
|
foreignColumns: [credentialAccess.id],
|
||||||
|
name: "shared_cred_secrets_access_id_fk",
|
||||||
|
}).onDelete("cascade"),
|
||||||
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
table.credentialAccessId,
|
table.credentialAccessId,
|
||||||
table.targetUserId,
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
real,
|
real,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
foreignKey,
|
||||||
type AnySQLiteColumn,
|
type AnySQLiteColumn,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
@@ -2095,9 +2096,7 @@ export const sharedCredentialSecrets = sqliteTable(
|
|||||||
"shared_credential_secrets",
|
"shared_credential_secrets",
|
||||||
{
|
{
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
credentialAccessId: integer("credential_access_id")
|
credentialAccessId: integer("credential_access_id").notNull(),
|
||||||
.notNull()
|
|
||||||
.references(() => credentialAccess.id, { onDelete: "cascade" }),
|
|
||||||
targetUserId: text("target_user_id")
|
targetUserId: text("target_user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -2122,6 +2121,11 @@ export const sharedCredentialSecrets = sqliteTable(
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.credentialAccessId],
|
||||||
|
foreignColumns: [credentialAccess.id],
|
||||||
|
name: "shared_cred_secrets_access_id_fk",
|
||||||
|
}).onDelete("cascade"),
|
||||||
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
table.credentialAccessId,
|
table.credentialAccessId,
|
||||||
table.targetUserId,
|
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 { SecretSourceRepository } from "./secret-source-repository.js";
|
||||||
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
||||||
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
|
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
|
||||||
|
import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-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 {
|
export function createCurrentCredentialAccessRepository(): CredentialAccessRepository {
|
||||||
return new CredentialAccessRepository(
|
return new CredentialAccessRepository(
|
||||||
createCurrentRepositoryContext(),
|
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,
|
createCurrentCredentialRepository,
|
||||||
createCurrentFileManagerBookmarkRepository,
|
createCurrentFileManagerBookmarkRepository,
|
||||||
createCurrentHostFolderRepository,
|
createCurrentHostFolderRepository,
|
||||||
|
createCurrentFolderAccessRepository,
|
||||||
createCurrentRecentActivityRepository,
|
createCurrentRecentActivityRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
@@ -92,6 +93,11 @@ export function registerHostFolderRoutes(
|
|||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
);
|
);
|
||||||
|
await createCurrentFolderAccessRepository().renameFolder(
|
||||||
|
userId,
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Folder renamed successfully",
|
message: "Folder renamed successfully",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js";
|
||||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
@@ -503,6 +504,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createdHost = result;
|
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 baseHost = transformHostResponse(createdHost);
|
||||||
|
|
||||||
const resolvedHost =
|
const resolvedHost =
|
||||||
@@ -1372,6 +1387,21 @@ router.put(
|
|||||||
sshDataObj,
|
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
|
// Keep every recipient's re-encrypted secret snapshots in sync with
|
||||||
// the updated host record.
|
// the updated host record.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "../../utils/permission-catalog.js";
|
} from "../../utils/permission-catalog.js";
|
||||||
import {
|
import {
|
||||||
createCurrentHostFolderRepository,
|
createCurrentHostFolderRepository,
|
||||||
|
createCurrentFolderAccessRepository,
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentCredentialAccessRepository,
|
createCurrentCredentialAccessRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
@@ -430,6 +431,22 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const expiresAt = expiryFromDuration(durationHours);
|
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 rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||||
const { SharedHostSecretsManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-host-secrets-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
@@ -583,6 +600,78 @@ router.post(
|
|||||||
* 500:
|
* 500:
|
||||||
* description: Failed to update grant.
|
* 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(
|
router.patch(
|
||||||
"/host/:id/access/:accessId",
|
"/host/:id/access/:accessId",
|
||||||
authenticateJWT,
|
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;
|
||||||
|
}
|
||||||
@@ -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(
|
export async function updateHostAccess(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
accessId: number,
|
accessId: number,
|
||||||
|
|||||||
@@ -1611,6 +1611,9 @@
|
|||||||
"shareWithCount": "Share ({{count}})",
|
"shareWithCount": "Share ({{count}})",
|
||||||
"currentAccess": "Current access",
|
"currentAccess": "Current access",
|
||||||
"noAccessEntries": "This host has not been shared yet",
|
"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",
|
"folderShareSummary": "Shared {{shared}} of {{total}} host(s) in this folder",
|
||||||
"grantedBy": "Granted by",
|
"grantedBy": "Granted by",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -177,6 +182,19 @@ export function HostShareModal({
|
|||||||
setAccessList(res.accessList ?? []);
|
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() {
|
async function handleShare() {
|
||||||
if ((!host && !folder) || selectedCount === 0) return;
|
if ((!host && !folder) || selectedCount === 0) return;
|
||||||
const targets: ShareTarget[] = [
|
const targets: ShareTarget[] = [
|
||||||
@@ -196,6 +214,7 @@ export function HostShareModal({
|
|||||||
permissionLevel,
|
permissionLevel,
|
||||||
...(durationHours ? { durationHours } : {}),
|
...(durationHours ? { durationHours } : {}),
|
||||||
});
|
});
|
||||||
|
await refreshFolderRules();
|
||||||
setFolderShareSummary({
|
setFolderShareSummary({
|
||||||
hostsShared: result.hostsShared,
|
hostsShared: result.hostsShared,
|
||||||
hostsTotal: result.hostsTotal,
|
hostsTotal: result.hostsTotal,
|
||||||
@@ -499,6 +518,42 @@ export function HostShareModal({
|
|||||||
</div>
|
</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 */}
|
{/* Current access: takes remaining space, scrolls independently */}
|
||||||
{!isFolderShare && (
|
{!isFolderShare && (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user