mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user