mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +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
@@ -250,6 +250,27 @@ jobs:
|
|||||||
path: termix_windows_arm64_portable.zip
|
path: termix_windows_arm64_portable.zip
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
# The same app with every official plugin, for installs without
|
||||||
|
# internet. Built last so nothing above packs the full set, and with
|
||||||
|
# no update files: a full install updates through the normal build and
|
||||||
|
# downloads the plugins it had on.
|
||||||
|
- name: Build Windows x64 full installer
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --win nsis --x64 --publish never '-c.nsis.artifactName=termix_windows_x64_full_nsis.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 full installer
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_windows_x64_full_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_windows_x64_full_nsis
|
||||||
|
path: release/termix_windows_x64_full_nsis.exe
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||||
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
||||||
@@ -488,6 +509,28 @@ jobs:
|
|||||||
path: release/com.karmaa.termix.flatpakref
|
path: release/com.karmaa.termix.flatpakref
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
# Every official plugin, for installs without internet. No update files.
|
||||||
|
- name: Build Linux x64 full packages
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --linux AppImage deb --x64 --publish never \
|
||||||
|
'-c.appImage.artifactName=termix_linux_x64_full_appimage.${ext}' \
|
||||||
|
'-c.deb.artifactName=termix_linux_x64_full_deb.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux x64 full packages
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_linux_x64_full_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_linux_x64_full
|
||||||
|
path: |
|
||||||
|
release/termix_linux_x64_full_appimage.AppImage
|
||||||
|
release/termix_linux_x64_full_deb.deb
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: blacksmith-6vcpu-macos-latest
|
runs-on: blacksmith-6vcpu-macos-latest
|
||||||
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
||||||
@@ -763,6 +806,32 @@ jobs:
|
|||||||
|
|
||||||
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
||||||
|
|
||||||
|
# Every official plugin, for installs without internet. Signed like the
|
||||||
|
# normal DMG, with no update files.
|
||||||
|
- name: Build macOS universal full DMG
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --mac dmg --universal --publish never \
|
||||||
|
'-c.dmg.artifactName=termix_macos_${arch}_full_dmg.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS universal full DMG
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_macos_universal_full_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_macos_universal_full_dmg
|
||||||
|
path: release/termix_macos_universal_full_dmg.dmg
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
- name: Clean up keychains
|
- name: Clean up keychains
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- A sidebar setting to turn off alternating row colors
|
- A sidebar setting to turn off alternating row colors
|
||||||
- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added
|
- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added
|
||||||
- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders
|
- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders
|
||||||
|
- A personal login set once on a folder is used for every host shared with you in it and its subfolders, unless a host has its own
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `shared_folder_auth_overrides` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`user_id` varchar(255) NOT NULL,
|
||||||
|
`folder` varchar(255) NOT NULL,
|
||||||
|
`protocol` varchar(255) NOT NULL DEFAULT 'ssh',
|
||||||
|
`credential_id` int NOT NULL,
|
||||||
|
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `shared_folder_auth_overrides_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `shared_folder_auth_overrides_user_folder_protocol_unique` UNIQUE(`user_id`,`folder`,`protocol`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -540,6 +540,13 @@
|
|||||||
"when": 1792945361809,
|
"when": 1792945361809,
|
||||||
"tag": "0076_host_color_icon",
|
"tag": "0076_host_color_icon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 77,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1793304929772,
|
||||||
|
"tag": "0077_shared_folder_auth_overrides",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "shared_folder_auth_overrides" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"folder" varchar(255) NOT NULL,
|
||||||
|
"protocol" varchar(255) DEFAULT 'ssh' NOT NULL,
|
||||||
|
"credential_id" integer NOT NULL,
|
||||||
|
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "shared_folder_auth_overrides_user_folder_protocol_unique" ON "shared_folder_auth_overrides" USING btree ("user_id","folder","protocol");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -540,6 +540,13 @@
|
|||||||
"when": 1792945360507,
|
"when": 1792945360507,
|
||||||
"tag": "0076_host_color_icon",
|
"tag": "0076_host_color_icon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 77,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793304928399,
|
||||||
|
"tag": "0077_shared_folder_auth_overrides",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `shared_folder_auth_overrides` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`folder` text NOT NULL,
|
||||||
|
`protocol` text DEFAULT 'ssh' NOT NULL,
|
||||||
|
`credential_id` integer NOT NULL,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `shared_folder_auth_overrides_user_folder_protocol_unique` ON `shared_folder_auth_overrides` (`user_id`,`folder`,`protocol`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -505,6 +505,13 @@
|
|||||||
"when": 1792945434651,
|
"when": 1792945434651,
|
||||||
"tag": "0071_host_color_icon",
|
"tag": "0071_host_color_icon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1793304926942,
|
||||||
|
"tag": "0072_shared_folder_auth_overrides",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@ Plugin API 1.13.0.
|
|||||||
- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
|
- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
|
||||||
- Rail items take `useStatus` for a small ok, warning or error dot on the icon
|
- Rail items take `useStatus` for a small ok, warning or error dot on the icon
|
||||||
- `shell.editHost(hostId)` opens the editor of a saved host
|
- `shell.editHost(hostId)` opens the editor of a saved host
|
||||||
|
- A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
|
||||||
|
- `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -495,6 +495,23 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
|
|||||||
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
||||||
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
||||||
|
|
||||||
|
A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
|
||||||
|
library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
|
||||||
|
`write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
|
||||||
|
`syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.sync.registerEntity({
|
||||||
|
type: "sharedIcons",
|
||||||
|
table: null,
|
||||||
|
readOnly: true,
|
||||||
|
order: 70,
|
||||||
|
load: (userId) => iconsVisibleTo(userId),
|
||||||
|
write: (userId, row) => storeCopy(userId, row),
|
||||||
|
erase: (userId, syncId) => dropCopy(userId, syncId),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
@@ -177,8 +177,24 @@ export interface SyncWriteEvent {
|
|||||||
export interface SyncEntityRegistration {
|
export interface SyncEntityRegistration {
|
||||||
/** Stable wire name. Never change it: records on both sides match on it. */
|
/** Stable wire name. Never change it: records on both sides match on it. */
|
||||||
type: string;
|
type: string;
|
||||||
/** The table, as returned by ctx.db.define. */
|
/** The table, as returned by ctx.db.define. Null for an entity with `load`. */
|
||||||
table: unknown;
|
table: unknown;
|
||||||
|
/**
|
||||||
|
* Server to desktop only: a desktop never pushes these rows. Required with
|
||||||
|
* `load`.
|
||||||
|
*/
|
||||||
|
readOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* Builds the rows the user gets instead of reading the rows they own in
|
||||||
|
* `table`, for data kept for the whole server (a shared library, rows other
|
||||||
|
* users share with them). Rows are on the wire: plaintext, each with a
|
||||||
|
* syncId. Needs `readOnly: true`, `write` and `erase`.
|
||||||
|
*/
|
||||||
|
load?: (userId: string) => Promise<SyncRow[]>;
|
||||||
|
/** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
|
||||||
|
write?: (userId: string, wire: SyncRow) => Promise<void>;
|
||||||
|
/** Drops the desktop copy with this syncId, for an entity with `load`. */
|
||||||
|
erase?: (userId: string, syncId: string) => Promise<void>;
|
||||||
/** Column holding the owning user id. Defaults to "userId". */
|
/** Column holding the owning user id. Defaults to "userId". */
|
||||||
userColumn?: string;
|
userColumn?: string;
|
||||||
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ getDeviceId (value): () => string
|
|||||||
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||||
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||||
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||||
|
getFolderAuthOverride (value): (folder: string, protocol: string, sharedCopySyncId?: string) => Promise<{ folder: string; protocol: string; credentialId: number; }>
|
||||||
getFolderDefaultsId (value): (name: string) => Promise<number>
|
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||||
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||||
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||||
@@ -526,7 +527,7 @@ resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
|||||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
revokeFolderAccess (value): (ruleId: number, options?: { fromHosts?: boolean; }) => Promise<void>
|
||||||
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||||
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
rightDockableIds (value): () => string[]
|
rightDockableIds (value): () => string[]
|
||||||
@@ -544,6 +545,7 @@ saveHostTags (value): (tags: string[]) => Promise<string[]>
|
|||||||
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||||
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||||
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||||
|
setFolderAuthOverride (value): (folder: string, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; folder: string; protocol: string; credentialId: number; }>
|
||||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ const ALLOWED = [
|
|||||||
"getFolderAccess",
|
"getFolderAccess",
|
||||||
"getFolderDefaultsId",
|
"getFolderDefaultsId",
|
||||||
"getHostAccess",
|
"getHostAccess",
|
||||||
|
"getFolderAuthOverride",
|
||||||
"getHostAuthOverride",
|
"getHostAuthOverride",
|
||||||
"getHostDefaultsLevel",
|
"getHostDefaultsLevel",
|
||||||
"getHostEditorSection",
|
"getHostEditorSection",
|
||||||
@@ -565,6 +566,7 @@ const ALLOWED = [
|
|||||||
"saveUserPreferences",
|
"saveUserPreferences",
|
||||||
"setAllPluginChannels",
|
"setAllPluginChannels",
|
||||||
"setDeveloperMode",
|
"setDeveloperMode",
|
||||||
|
"setFolderAuthOverride",
|
||||||
"setHostAuthOverride",
|
"setHostAuthOverride",
|
||||||
"setNotificationPrivateEndpoints",
|
"setNotificationPrivateEndpoints",
|
||||||
"setPluginOptions",
|
"setPluginOptions",
|
||||||
|
|||||||
@@ -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 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 (
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
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(
|
export const sharedHostSecrets = mysqlTable(
|
||||||
"shared_host_secrets",
|
"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(
|
export const sharedHostSecrets = pgTable(
|
||||||
"shared_host_secrets",
|
"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(
|
export const sharedHostSecrets = sqliteTable(
|
||||||
"shared_host_secrets",
|
"shared_host_secrets",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
|
|||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
||||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-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 { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||||
import { TrustedDeviceRepository } from "./trusted-device-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 {
|
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||||
return new SshCredentialUsageRepository(
|
return new SshCredentialUsageRepository(
|
||||||
createCurrentRepositoryContext(),
|
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,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
|
createCurrentSharedFolderAuthOverrideRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||||
|
|
||||||
const router = express.Router();
|
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;
|
export default router;
|
||||||
@@ -87,11 +87,18 @@ export async function resolveHostCredentials(
|
|||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
...authOverrides,
|
...authOverrides,
|
||||||
ssh: {
|
ssh: resolution.folder
|
||||||
credentialId: resolution.credentialId,
|
? {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
},
|
folder: resolution.folder,
|
||||||
|
folderCredentialId: resolution.credentialId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
credentialId: resolution.credentialId,
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
authType:
|
authType:
|
||||||
credential.key || credential.privateKey
|
credential.key || credential.privateKey
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
SyncRow,
|
SyncRow,
|
||||||
} from "@termix-ssh/plugin-sdk/backend";
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
|
||||||
/** Core-only extras a plugin registration cannot set. */
|
/** Extras core sets for its own entities. A plugin may set the first four. */
|
||||||
export interface CoreSyncEntityOptions {
|
export interface CoreSyncEntityOptions {
|
||||||
/** Server-to-desktop only. A desktop never pushes it. */
|
/** Server-to-desktop only. A desktop never pushes it. */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -58,6 +58,54 @@ export interface RegisteredSyncEntity
|
|||||||
encryptedFields: readonly string[];
|
encryptedFields: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a plugin registration may carry. Anything else is dropped. */
|
||||||
|
const PLUGIN_KEYS = [
|
||||||
|
"type",
|
||||||
|
"table",
|
||||||
|
"userColumn",
|
||||||
|
"encryptedFields",
|
||||||
|
"references",
|
||||||
|
"order",
|
||||||
|
"readOnlyFields",
|
||||||
|
"singleton",
|
||||||
|
"answersTo",
|
||||||
|
"shouldSync",
|
||||||
|
"permissions",
|
||||||
|
"serialize",
|
||||||
|
"deserialize",
|
||||||
|
"afterWrite",
|
||||||
|
"remove",
|
||||||
|
"readOnly",
|
||||||
|
"load",
|
||||||
|
"write",
|
||||||
|
"erase",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
|
||||||
|
const picked: Record<string, unknown> = {};
|
||||||
|
for (const key of PLUGIN_KEYS) {
|
||||||
|
if (entity[key] !== undefined) picked[key] = entity[key];
|
||||||
|
}
|
||||||
|
const out = picked as unknown as SyncEntityRegistration;
|
||||||
|
if (out.load || out.write || out.erase) {
|
||||||
|
if (!out.load || !out.write || !out.erase) {
|
||||||
|
throw new Error(
|
||||||
|
`Sync entity "${entity.type}" needs load, write and erase together`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A plugin only ever syncs its own rows both ways; rows it builds for
|
||||||
|
// a user come down as read only copies.
|
||||||
|
if (out.readOnly !== true) {
|
||||||
|
throw new Error(
|
||||||
|
`Sync entity "${entity.type}" with load must be readOnly`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!out.table) {
|
||||||
|
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const entities = new Map<string, RegisteredSyncEntity>();
|
const entities = new Map<string, RegisteredSyncEntity>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
@@ -81,7 +129,7 @@ export function registerEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registered: RegisteredSyncEntity = {
|
const registered: RegisteredSyncEntity = {
|
||||||
...entity,
|
...(owner === CORE_OWNER ? entity : pluginEntity(entity)),
|
||||||
...(owner === CORE_OWNER ? core : {}),
|
...(owner === CORE_OWNER ? core : {}),
|
||||||
owner,
|
owner,
|
||||||
order: entity.order ?? 100,
|
order: entity.order ?? 100,
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
folderAndParents,
|
||||||
|
SharedFolderAuthOverrideRepository,
|
||||||
|
} from "../../../database/repositories/shared-folder-auth-override-repository.js";
|
||||||
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
|
|
||||||
|
describe("folderAndParents", () => {
|
||||||
|
it("lists the folder, then each folder above it", () => {
|
||||||
|
expect(folderAndParents("Prod / Web / EU")).toEqual([
|
||||||
|
"Prod / Web / EU",
|
||||||
|
"Prod / Web",
|
||||||
|
"Prod",
|
||||||
|
]);
|
||||||
|
expect(folderAndParents(" Prod ")).toEqual(["Prod"]);
|
||||||
|
expect(folderAndParents("")).toEqual([]);
|
||||||
|
expect(folderAndParents(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SharedFolderAuthOverrideRepository", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await adapter?.close();
|
||||||
|
adapter = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRepository(onWrite?: () => void) {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
await adapter.exec(`
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('recipient', 'recipient', 'hash'), ('other', 'other', 'hash');
|
||||||
|
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
|
||||||
|
VALUES (7, 'recipient', 'cred-seven', 'password'),
|
||||||
|
(8, 'recipient', 'cred-eight', 'password');
|
||||||
|
`);
|
||||||
|
return new SharedFolderAuthOverrideRepository(context, onWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sets, updates and clears a login per user, folder and protocol", async () => {
|
||||||
|
let writes = 0;
|
||||||
|
const repository = await createRepository(() => {
|
||||||
|
writes += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||||
|
await repository.setCredential("recipient", "Prod", "ssh", 8);
|
||||||
|
await repository.setCredential("recipient", "Prod", "rdp", 7);
|
||||||
|
await expect(
|
||||||
|
repository.findCredentialId("recipient", "Prod", "ssh"),
|
||||||
|
).resolves.toBe(8);
|
||||||
|
await expect(
|
||||||
|
repository.findCredentialId("other", "Prod", "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.clearCredential("recipient", "Prod", "ssh"),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
await expect(
|
||||||
|
repository.clearCredential("recipient", "Prod", "ssh"),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
expect(writes).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the login of the closest folder above a host", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||||
|
await repository.setCredential("recipient", "Prod / Web", "ssh", 8);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", "Prod / Web / EU", "ssh"),
|
||||||
|
).resolves.toEqual({ folder: "Prod / Web", credentialId: 8 });
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", "Prod / DB", "ssh"),
|
||||||
|
).resolves.toEqual({ folder: "Prod", credentialId: 7 });
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", "Staging", "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", "Prod", "rdp"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", null, "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the login when its credential is deleted", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||||
|
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
|
||||||
|
await expect(
|
||||||
|
repository.findCredentialId("recipient", "Prod", "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,11 @@ const state = vi.hoisted(() => ({
|
|||||||
credentialUsable: true,
|
credentialUsable: true,
|
||||||
credentialId: 7 as number | null,
|
credentialId: 7 as number | null,
|
||||||
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
|
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
|
||||||
|
folderWrites: [] as Array<{
|
||||||
|
folder: string;
|
||||||
|
protocol: string;
|
||||||
|
credentialId: number | null;
|
||||||
|
}>,
|
||||||
auditCalls: [] as Array<Record<string, unknown>>,
|
auditCalls: [] as Array<Record<string, unknown>>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -97,6 +102,25 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
createCurrentSharedFolderAuthOverrideRepository: () => ({
|
||||||
|
findCredentialId: async () => 7,
|
||||||
|
setCredential: async (
|
||||||
|
_userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: string,
|
||||||
|
credentialId: number,
|
||||||
|
) => {
|
||||||
|
state.folderWrites.push({ folder, protocol, credentialId });
|
||||||
|
},
|
||||||
|
clearCredential: async (
|
||||||
|
_userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: string,
|
||||||
|
) => {
|
||||||
|
state.folderWrites.push({ folder, protocol, credentialId: null });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
createCurrentUserRepository: () => ({
|
createCurrentUserRepository: () => ({
|
||||||
findById: async () => ({ id: "recipient", username: "recipient" }),
|
findById: async () => ({ id: "recipient", username: "recipient" }),
|
||||||
}),
|
}),
|
||||||
@@ -109,6 +133,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
|
|
||||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||||
|
getAuditUsername: async (userId: string) => userId,
|
||||||
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
|
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
|
||||||
state.auditCalls.push(entry);
|
state.auditCalls.push(entry);
|
||||||
}),
|
}),
|
||||||
@@ -134,6 +159,8 @@ describe("shared host authentication override routes", () => {
|
|||||||
method: "get" | "put",
|
method: "get" | "put",
|
||||||
body: Record<string, unknown> = {},
|
body: Record<string, unknown> = {},
|
||||||
protocol = "ssh",
|
protocol = "ssh",
|
||||||
|
path = "/host-access/:hostId/auth/:protocol",
|
||||||
|
query: Record<string, unknown> = {},
|
||||||
): Promise<{ status: number; body: unknown }> {
|
): Promise<{ status: number; body: unknown }> {
|
||||||
const routeLayer = (
|
const routeLayer = (
|
||||||
router as unknown as {
|
router as unknown as {
|
||||||
@@ -152,15 +179,14 @@ describe("shared host authentication override routes", () => {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
).stack.find(
|
).stack.find(
|
||||||
(layer) =>
|
(layer) => layer.route?.path === path && layer.route.methods[method],
|
||||||
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
|
|
||||||
layer.route.methods[method],
|
|
||||||
);
|
);
|
||||||
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
|
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
|
||||||
|
|
||||||
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
|
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
|
||||||
const req = {
|
const req = {
|
||||||
params: { hostId: "42", protocol },
|
params: { hostId: "42", protocol },
|
||||||
|
query,
|
||||||
body,
|
body,
|
||||||
headers: {},
|
headers: {},
|
||||||
ip: "127.0.0.1",
|
ip: "127.0.0.1",
|
||||||
@@ -210,9 +236,103 @@ describe("shared host authentication override routes", () => {
|
|||||||
state.credentialUsable = true;
|
state.credentialUsable = true;
|
||||||
state.credentialId = 7;
|
state.credentialId = 7;
|
||||||
state.writes = [];
|
state.writes = [];
|
||||||
|
state.folderWrites = [];
|
||||||
state.auditCalls = [];
|
state.auditCalls = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("folder logins", () => {
|
||||||
|
const FOLDER = "/folder-auth/:protocol";
|
||||||
|
|
||||||
|
it("reads the login for a folder", async () => {
|
||||||
|
const response = await invoke("get", {}, "ssh", FOLDER, {
|
||||||
|
folder: " Prod / Web ",
|
||||||
|
});
|
||||||
|
expect(response).toEqual({
|
||||||
|
status: 200,
|
||||||
|
body: { folder: "Prod / Web", protocol: "ssh", credentialId: 7 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets and clears the login and audits both", async () => {
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: 8 },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: null },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(state.folderWrites).toEqual([
|
||||||
|
{ folder: "Prod", protocol: "ssh", credentialId: 8 },
|
||||||
|
{ folder: "Prod", protocol: "ssh", credentialId: null },
|
||||||
|
]);
|
||||||
|
expect(state.auditCalls.map((call) => call.action)).toEqual([
|
||||||
|
"set_shared_folder_auth_override",
|
||||||
|
"clear_shared_folder_auth_override",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bad folder, protocol or credential", async () => {
|
||||||
|
expect(
|
||||||
|
(await invoke("get", {}, "ssh", FOLDER, { folder: "" })).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "x".repeat(300), credentialId: 8 },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: 8 },
|
||||||
|
"smtp",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: -1 },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
state.credentialUsable = false;
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: 99 },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect(state.folderWrites).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the current override for a role-derived shared recipient", async () => {
|
it("returns the current override for a role-derived shared recipient", async () => {
|
||||||
const response = await invoke("get");
|
const response = await invoke("get");
|
||||||
expect(response).toEqual({
|
expect(response).toEqual({
|
||||||
|
|||||||
@@ -117,6 +117,56 @@ describe("entity aliases", () => {
|
|||||||
expect(getEntity("thing")?.readOnly).toBeUndefined();
|
expect(getEntity("thing")?.readOnly).toBeUndefined();
|
||||||
expect(getEntity("thing")?.load).toBeUndefined();
|
expect(getEntity("thing")?.load).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a plugin sync rows it builds as read only copies", () => {
|
||||||
|
const load = async () => [];
|
||||||
|
registerEntity("demo", {
|
||||||
|
type: "sharedIcons",
|
||||||
|
table: null,
|
||||||
|
readOnly: true,
|
||||||
|
load,
|
||||||
|
write: async () => undefined,
|
||||||
|
erase: async () => undefined,
|
||||||
|
});
|
||||||
|
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a plugin entity that loads rows but can be pushed", () => {
|
||||||
|
expect(() =>
|
||||||
|
registerEntity("demo", {
|
||||||
|
type: "pushable",
|
||||||
|
table: null,
|
||||||
|
load: async () => [],
|
||||||
|
write: async () => undefined,
|
||||||
|
erase: async () => undefined,
|
||||||
|
}),
|
||||||
|
).toThrow(/readOnly/);
|
||||||
|
expect(() =>
|
||||||
|
registerEntity("demo", {
|
||||||
|
type: "halfDone",
|
||||||
|
table: null,
|
||||||
|
readOnly: true,
|
||||||
|
load: async () => [],
|
||||||
|
}),
|
||||||
|
).toThrow(/load, write and erase/);
|
||||||
|
expect(() =>
|
||||||
|
registerEntity("demo", { type: "nothing", table: null }),
|
||||||
|
).toThrow(/table or load/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops core only keys a plugin puts on its registration", () => {
|
||||||
|
registerEntity("demo", {
|
||||||
|
type: "sneaky",
|
||||||
|
table: {},
|
||||||
|
covers: () => true,
|
||||||
|
canReference: async () => true,
|
||||||
|
hashIgnore: ["x"],
|
||||||
|
} as never);
|
||||||
|
const entity = getEntity("sneaky")!;
|
||||||
|
expect(entity.covers).toBeUndefined();
|
||||||
|
expect(entity.canReference).toBeUndefined();
|
||||||
|
expect(entity.hashIgnore).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("core sync entities", () => {
|
describe("core sync entities", () => {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
hostOverride: null as number | null,
|
||||||
|
folderOverride: null as { folder: string; credentialId: number } | null,
|
||||||
|
ownerSecret: null as Record<string, unknown> | null,
|
||||||
|
usable: new Set<number>(),
|
||||||
|
folderLookups: [] as Array<{ folder: unknown; protocol: string }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hosts/protocol-auth/registry.js", () => ({
|
||||||
|
isAuthOverrideProtocol: (protocol: string) => protocol === "ssh",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||||
|
findUsableCredential: async (id: number) =>
|
||||||
|
state.usable.has(id) ? { id, username: `user-${id}` } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
findHostOwnerId: async () => "owner",
|
||||||
|
}),
|
||||||
|
createCurrentRbacAccessRepository: () => ({
|
||||||
|
findActiveHostAccess: async () => null,
|
||||||
|
}),
|
||||||
|
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
|
||||||
|
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||||
|
findCredentialId: async () => state.hostOverride,
|
||||||
|
}),
|
||||||
|
createCurrentSharedFolderAuthOverrideRepository: () => ({
|
||||||
|
findClosest: async (_userId: string, folder: unknown, protocol: string) => {
|
||||||
|
state.folderLookups.push({ folder, protocol });
|
||||||
|
return state.folderOverride;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||||
|
SharedHostSecretsManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getSecretForUser: async () => state.ownerSecret,
|
||||||
|
snapshotForUser: async () => undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { resolveRecipientSharedHostAuthentication } =
|
||||||
|
await import("../../utils/shared-host-auth-resolver.js");
|
||||||
|
|
||||||
|
const host = (extra: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
id: 42,
|
||||||
|
userId: "owner",
|
||||||
|
folder: "Prod / Web",
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 99,
|
||||||
|
shareSshAuth: false,
|
||||||
|
...extra,
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.hostOverride = null;
|
||||||
|
state.folderOverride = null;
|
||||||
|
state.ownerSecret = null;
|
||||||
|
state.usable = new Set();
|
||||||
|
state.folderLookups = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("folder logins for shared hosts", () => {
|
||||||
|
it("uses the folder's login when the host has none of its own", async () => {
|
||||||
|
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||||
|
state.usable.add(7);
|
||||||
|
const result = await resolveRecipientSharedHostAuthentication(
|
||||||
|
host(),
|
||||||
|
42,
|
||||||
|
"recipient",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
source: "personal-override",
|
||||||
|
credentialId: 7,
|
||||||
|
folder: "Prod",
|
||||||
|
});
|
||||||
|
expect(state.folderLookups).toEqual([
|
||||||
|
{ folder: "Prod / Web", protocol: "ssh" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the host's own login win over the folder's", async () => {
|
||||||
|
state.hostOverride = 5;
|
||||||
|
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||||
|
state.usable.add(5).add(7);
|
||||||
|
const result = await resolveRecipientSharedHostAuthentication(
|
||||||
|
host(),
|
||||||
|
42,
|
||||||
|
"recipient",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ credentialId: 5 });
|
||||||
|
expect(result).not.toHaveProperty("folder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a folder login the user can no longer use", async () => {
|
||||||
|
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||||
|
state.ownerSecret = { authType: "password", password: "x" };
|
||||||
|
const result = await resolveRecipientSharedHostAuthentication(
|
||||||
|
host({ shareSshAuth: true }),
|
||||||
|
42,
|
||||||
|
"recipient",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ source: "owner-shared" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a login when nothing is set", async () => {
|
||||||
|
const result = await resolveRecipientSharedHostAuthentication(
|
||||||
|
host(),
|
||||||
|
42,
|
||||||
|
"recipient",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ source: "required" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
|
createCurrentSharedFolderAuthOverrideRepository,
|
||||||
createCurrentSharedHostAuthOverrideRepository,
|
createCurrentSharedHostAuthOverrideRepository,
|
||||||
} from "../database/repositories/factory.js";
|
} from "../database/repositories/factory.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -23,6 +24,8 @@ export type RecipientSharedHostAuthResolution =
|
|||||||
source: "personal-override";
|
source: "personal-override";
|
||||||
credentialId: number;
|
credentialId: number;
|
||||||
credential: HostResolutionCredentialRecord;
|
credential: HostResolutionCredentialRecord;
|
||||||
|
/** Set when the login comes from the folder, not the host itself. */
|
||||||
|
folder?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
source: "owner-shared";
|
source: "owner-shared";
|
||||||
@@ -54,8 +57,8 @@ function isOwnerAuthShared(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the shared-host authentication precedence independently from any
|
* Applies the shared-host authentication precedence independently from any
|
||||||
* transport: recipient override, shared owner auth, secretless auth, then
|
* transport: recipient override on the host, then on its folder, shared
|
||||||
* "required".
|
* owner auth, secretless auth, then "required".
|
||||||
*/
|
*/
|
||||||
export async function resolveRecipientSharedHostAuthentication(
|
export async function resolveRecipientSharedHostAuthentication(
|
||||||
host: HostResolutionHostRecord,
|
host: HostResolutionHostRecord,
|
||||||
@@ -91,6 +94,31 @@ export async function resolveRecipientSharedHostAuthentication(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (isOwnerAuthShared(host, protocol)) {
|
||||||
if (protocol === "ssh" && host.authType === "agent") {
|
if (protocol === "ssh" && host.authType === "agent") {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -388,6 +388,54 @@ export async function getHostAuthOverride(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's own login for hosts shared with them in a folder. On a desktop
|
||||||
|
* linked to a server, pass a shared host from the folder so the call goes to
|
||||||
|
* that server.
|
||||||
|
*/
|
||||||
|
export async function getFolderAuthOverride(
|
||||||
|
folder: string,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
sharedCopySyncId?: string | null,
|
||||||
|
): Promise<{
|
||||||
|
folder: string;
|
||||||
|
protocol: AuthOverrideProtocol;
|
||||||
|
credentialId: number | null;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const target = await getSharedCopyTarget(0, sharedCopySyncId);
|
||||||
|
const response = await target.api.get(`/rbac/folder-auth/${protocol}`, {
|
||||||
|
params: { folder },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch folder authentication override");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFolderAuthOverride(
|
||||||
|
folder: string,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
credentialId: number | null,
|
||||||
|
sharedCopySyncId?: string | null,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
folder: string;
|
||||||
|
protocol: AuthOverrideProtocol;
|
||||||
|
credentialId: number | null;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const target = await getSharedCopyTarget(0, sharedCopySyncId);
|
||||||
|
const response = await target.api.put(`/rbac/folder-auth/${protocol}`, {
|
||||||
|
folder,
|
||||||
|
credentialId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "update folder authentication override");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function setHostAuthOverride(
|
export async function setHostAuthOverride(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
protocol: AuthOverrideProtocol,
|
protocol: AuthOverrideProtocol,
|
||||||
|
|||||||
@@ -1317,6 +1317,8 @@ export {
|
|||||||
revokeHostAccess,
|
revokeHostAccess,
|
||||||
getHostAuthOverride,
|
getHostAuthOverride,
|
||||||
setHostAuthOverride,
|
setHostAuthOverride,
|
||||||
|
getFolderAuthOverride,
|
||||||
|
setFolderAuthOverride,
|
||||||
getPermissionsCatalog,
|
getPermissionsCatalog,
|
||||||
getSharedHosts,
|
getSharedHosts,
|
||||||
} from "@/api/rbac-api";
|
} from "@/api/rbac-api";
|
||||||
|
|||||||
@@ -640,6 +640,7 @@ export {
|
|||||||
getExternalAutoProvision,
|
getExternalAutoProvision,
|
||||||
getHostAccess,
|
getHostAccess,
|
||||||
getHostAuthOverride,
|
getHostAuthOverride,
|
||||||
|
getFolderAuthOverride,
|
||||||
getHostPassword,
|
getHostPassword,
|
||||||
getHostPolicy,
|
getHostPolicy,
|
||||||
type HostPolicy,
|
type HostPolicy,
|
||||||
@@ -682,6 +683,7 @@ export {
|
|||||||
saveCredentialSidebarPreferences,
|
saveCredentialSidebarPreferences,
|
||||||
saveHostSidebarPreferences,
|
saveHostSidebarPreferences,
|
||||||
setHostAuthOverride,
|
setHostAuthOverride,
|
||||||
|
setFolderAuthOverride,
|
||||||
shareFolder,
|
shareFolder,
|
||||||
shareHost,
|
shareHost,
|
||||||
type SSHHostWithStatus,
|
type SSHHostWithStatus,
|
||||||
|
|||||||
Reference in new issue
Block a user