Files
Termix/src/backend/utils/shared-host-auth-resolver.ts
T

203 lines
6.0 KiB
TypeScript

import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSharedFolderAuthOverrideRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../database/repositories/factory.js";
import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../database/repositories/host-resolution-repository.js";
import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { findUsableCredential } from "../hosts/usable-credential.js";
import {
SharedHostSecretsManager,
type SharedSecretData,
} from "./shared-host-secrets-manager.js";
export type RecipientSharedHostAuthResolution =
| {
source: "personal-override";
credentialId: number;
credential: HostResolutionCredentialRecord;
/** Set when the login comes from the folder, not the host itself. */
folder?: string;
}
| {
source: "owner-shared";
authType: string;
secret: SharedSecretData | null;
}
| { source: "secretless" }
| { source: "required" };
export function requiresPersonalHostAuthentication(
host: Pick<HostResolutionHostRecord, "credentialId" | "authType">,
protocol: AuthOverrideProtocol,
): boolean {
// Owner auth for a plugin protocol is snapshotted for every recipient, so
// only SSH, which sits behind shareSshAuth, can leave one without auth.
if (protocol !== "ssh") return false;
if (host.credentialId) return true;
ensureCoreSshAuthProviders();
return !!getSshAuthProvider(host.authType ?? "none")?.requiresSecret;
}
/** Whether the owner's auth for this protocol is available to recipients. */
function isOwnerAuthShared(
host: Pick<HostResolutionHostRecord, "shareSshAuth">,
protocol: AuthOverrideProtocol,
): boolean {
return protocol === "ssh" ? !!host.shareSshAuth : true;
}
/**
* Applies the shared-host authentication precedence independently from any
* transport: recipient override on the host, then on its folder, shared
* owner auth, secretless auth, then "required".
*/
export async function resolveRecipientSharedHostAuthentication(
host: HostResolutionHostRecord,
hostId: number,
userId: string,
protocol: AuthOverrideProtocol,
): Promise<RecipientSharedHostAuthResolution> {
if (!isAuthOverrideProtocol(protocol)) {
throw new Error(`No plugin declares the ${String(protocol)} protocol`);
}
const repository = createCurrentHostResolutionRepository();
let overrideCredentialId: number | null = null;
try {
overrideCredentialId =
await createCurrentSharedHostAuthOverrideRepository().findCredentialId(
hostId,
userId,
protocol,
);
} catch {
// A missing/deleted override behaves like no personal credential.
}
if (overrideCredentialId) {
const credential = await findUsableCredential(overrideCredentialId, userId);
if (credential) {
return {
source: "personal-override",
credentialId: overrideCredentialId,
credential,
};
}
}
try {
const folderOverride =
await createCurrentSharedFolderAuthOverrideRepository().findClosest(
userId,
host.folder,
protocol,
);
if (folderOverride) {
const credential = await findUsableCredential(
folderOverride.credentialId,
userId,
);
if (credential) {
return {
source: "personal-override",
credentialId: folderOverride.credentialId,
credential,
folder: folderOverride.folder,
};
}
}
} catch {
// Same as a host override: an unreadable one counts as none.
}
if (isOwnerAuthShared(host, protocol)) {
if (protocol === "ssh" && host.authType === "agent") {
return {
source: "owner-shared",
authType: "agent",
secret: null,
};
}
try {
const secretsManager = SharedHostSecretsManager.getInstance();
let secret = await secretsManager.getSecretForUser(
hostId,
userId,
protocol,
);
if (!secret) {
// No snapshot yet -- most often the grant was created while this
// recipient's data key was unavailable (snapshotForUser silently
// skips in that case). Mirrors the self-heal in findUsableCredential.
const hostAccessId = await findActiveHostAccessId(hostId, userId);
if (hostAccessId !== null) {
const ownerId = await repository.findHostOwnerId(hostId);
if (ownerId) {
await secretsManager.snapshotForUser(
hostAccessId,
hostId,
userId,
ownerId,
);
secret = await secretsManager.getSecretForUser(
hostId,
userId,
protocol,
);
}
}
}
if (secret) {
return {
source: "owner-shared",
authType: secret.authType,
secret,
};
}
} catch {
// An unreadable owner snapshot cannot expose the owner's auth.
}
}
if (protocol === "ssh" && host.credentialId) {
const credential = await findUsableCredential(host.credentialId, userId);
if (credential) {
return {
source: "personal-override",
credentialId: host.credentialId,
credential,
};
}
}
return requiresPersonalHostAuthentication(host, protocol)
? { source: "required" }
: { source: "secretless" };
}
/** The grant id for this user's access to the host, direct or via a role. */
async function findActiveHostAccessId(
hostId: number,
userId: string,
): Promise<number | null> {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const access = await createCurrentRbacAccessRepository().findActiveHostAccess(
hostId,
userId,
roleIds,
);
return access?.id ?? null;
}