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
+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;
}