mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: folder logins for shared hosts, read only plugin sync, full desktop builds
This commit is contained in:
1 parent
b32203d19b
commit
a0e13f9eeb
33 files changed
+15245
-14
No files matched your search
@@ -484,6 +484,19 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
credential_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
|
||||
@@ -490,6 +490,38 @@ export const sharedHostAuthOverrides = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = mysqlTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: varchar("folder", { length: 255 }).notNull(),
|
||||
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||
credentialId: int("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = mysqlTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -490,6 +490,38 @@ export const sharedHostAuthOverrides = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = pgTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: varchar("folder", { length: 255 }).notNull(),
|
||||
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = pgTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -480,6 +480,38 @@ export const sharedHostAuthOverrides = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = sqliteTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: text("folder").notNull(),
|
||||
protocol: text("protocol").notNull().default("ssh"),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = sqliteTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
@@ -316,6 +317,15 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
|
||||
return new SharedFolderAuthOverrideRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook(
|
||||
"shared_folder_auth_override_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||
return new SshCredentialUsageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||
import { sharedFolderAuthOverrides } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { deleteReturning, upsert } from "./returning.js";
|
||||
|
||||
/** "A / B / C" and the folders above it, closest first. */
|
||||
export function folderAndParents(folder: string | null | undefined): string[] {
|
||||
const parts = (folder ?? "")
|
||||
.split(" / ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
out.push(parts.slice(0, i).join(" / "));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SharedFolderAuthOverrideRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findCredentialId(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<number | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
|
||||
.from(sharedFolderAuthOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.folder, folder),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.credentialId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login set on the host's folder or the closest folder above it, for
|
||||
* one protocol. Null when none is set.
|
||||
*/
|
||||
async findClosest(
|
||||
userId: string,
|
||||
hostFolder: string | null | undefined,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<{ folder: string; credentialId: number } | null> {
|
||||
const folders = folderAndParents(hostFolder);
|
||||
if (folders.length === 0) return null;
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
folder: sharedFolderAuthOverrides.folder,
|
||||
credentialId: sharedFolderAuthOverrides.credentialId,
|
||||
})
|
||||
.from(sharedFolderAuthOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
inArray(sharedFolderAuthOverrides.folder, folders),
|
||||
),
|
||||
);
|
||||
for (const folder of folders) {
|
||||
const row = rows.find((candidate) => candidate.folder === folder);
|
||||
if (row) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async setCredential(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
credentialId: number,
|
||||
): Promise<void> {
|
||||
await upsert(
|
||||
this.context,
|
||||
sharedFolderAuthOverrides,
|
||||
{ userId, folder, protocol, credentialId },
|
||||
{
|
||||
target: [
|
||||
sharedFolderAuthOverrides.userId,
|
||||
sharedFolderAuthOverrides.folder,
|
||||
sharedFolderAuthOverrides.protocol,
|
||||
],
|
||||
set: { credentialId, updatedAt: new Date().toISOString() },
|
||||
},
|
||||
);
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async clearCredential(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<boolean> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
sharedFolderAuthOverrides,
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.folder, folder),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
),
|
||||
);
|
||||
if (rows.length > 0) await this.onWrite?.();
|
||||
return rows.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,10 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedFolderAuthOverrideRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -2225,4 +2227,157 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/** A folder path as hosts store it ("Parent / Child"), or null. */
|
||||
function folderParam(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const folder = value
|
||||
.split(" / ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
return folder && folder.length <= 255 ? folder : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/folder-auth/{protocol}:
|
||||
* get:
|
||||
* summary: Get the current user's personal login for shared hosts in a folder
|
||||
* tags: [RBAC]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: protocol
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: folder
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The folder, protocol and credential id, or null when none is set.
|
||||
* 400:
|
||||
* description: Invalid folder or protocol.
|
||||
*/
|
||||
router.get(
|
||||
"/folder-auth/:protocol",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const protocol = req.params.protocol;
|
||||
const folder = folderParam(req.query.folder);
|
||||
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||
if (!isAuthOverrideProtocol(protocol)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid authentication protocol" });
|
||||
}
|
||||
const credentialId =
|
||||
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
|
||||
userId,
|
||||
folder,
|
||||
protocol,
|
||||
);
|
||||
res.json({ folder, protocol, credentialId });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get folder login", error);
|
||||
res.status(500).json({ error: "Failed to fetch credential" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/folder-auth/{protocol}:
|
||||
* put:
|
||||
* summary: Set the current user's personal login for shared hosts in a folder
|
||||
* description: >
|
||||
* Used for every host shared with the user in this folder and its
|
||||
* subfolders that has no personal login of its own. credentialId null
|
||||
* clears it.
|
||||
* tags: [RBAC]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: protocol
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [folder, credentialId]
|
||||
* properties:
|
||||
* folder: { type: string }
|
||||
* credentialId: { type: integer, nullable: true }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: Invalid folder, protocol or credential id.
|
||||
* 404:
|
||||
* description: The credential is not one the user can use.
|
||||
*/
|
||||
router.put(
|
||||
"/folder-auth/:protocol",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const protocol = req.params.protocol;
|
||||
const folder = folderParam(req.body?.folder);
|
||||
const credentialId = req.body?.credentialId ?? null;
|
||||
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||
if (!isAuthOverrideProtocol(protocol)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid authentication protocol" });
|
||||
}
|
||||
if (
|
||||
credentialId !== null &&
|
||||
(!Number.isInteger(credentialId) || credentialId <= 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "credentialId must be a positive integer or null",
|
||||
});
|
||||
}
|
||||
|
||||
const repository = createCurrentSharedFolderAuthOverrideRepository();
|
||||
if (credentialId === null) {
|
||||
await repository.clearCredential(userId, folder, protocol);
|
||||
} else {
|
||||
if (!(await findUsableCredential(credentialId, userId))) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
await repository.setCredential(userId, folder, protocol, credentialId);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action:
|
||||
credentialId === null
|
||||
? "clear_shared_folder_auth_override"
|
||||
: "set_shared_folder_auth_override",
|
||||
resourceType: "folder",
|
||||
resourceName: folder,
|
||||
details: JSON.stringify({ protocol, credentialId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
}).catch(() => undefined);
|
||||
|
||||
res.json({ success: true, folder, protocol, credentialId });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to set folder login", error);
|
||||
res.status(500).json({ error: "Failed to update credential" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
Reference in new issue
Block a user