mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: share credentials with users and roles, inherit data on account deletion (#1342)
* feat: share credentials with users and roles, inherit data on account deletion Credentials can be shared at "use" or "manage" level. Recipients get a copy re-encrypted under their own data key (shared_credential_secrets), kept in step with the owner's row through the same lifecycle hooks as shared host secrets. One gate, findUsableCredential(), replaces the private-namespace lookups so a shared credential works wherever a private one does. Deleting a user now hands their hosts and credentials to a successor (the deleting admin by default) instead of revoking everything they shared. * fix: harden credential ownership transfer
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
CREATE TABLE `credential_access` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`credential_id` int NOT NULL,
|
||||||
|
`user_id` varchar(255),
|
||||||
|
`role_id` int,
|
||||||
|
`granted_by` varchar(255) NOT NULL,
|
||||||
|
`permission_level` text NOT NULL DEFAULT ('use'),
|
||||||
|
`expires_at` varchar(255),
|
||||||
|
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `credential_access_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `shared_credential_secrets` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`credential_access_id` int NOT NULL,
|
||||||
|
`target_user_id` varchar(255) NOT NULL,
|
||||||
|
`credential_id` int NOT NULL,
|
||||||
|
`encrypted_username` text,
|
||||||
|
`auth_type` text NOT NULL DEFAULT ('password'),
|
||||||
|
`encrypted_password` text,
|
||||||
|
`encrypted_key` text,
|
||||||
|
`encrypted_key_password` text,
|
||||||
|
`key_type` text,
|
||||||
|
`public_key` text,
|
||||||
|
`cert_public_key` text,
|
||||||
|
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `shared_credential_secrets_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `idx_shared_credential_secrets_scope` UNIQUE(`credential_access_id`,`target_user_id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_cred_secrets_access_id_fk` FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
|||||||
"when": 1787596414390,
|
"when": 1787596414390,
|
||||||
"tag": "0017_spicy_proteus",
|
"tag": "0017_spicy_proteus",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1787600445146,
|
||||||
|
"tag": "0018_fancy_barracuda",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE "credential_access" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"credential_id" integer NOT NULL,
|
||||||
|
"user_id" varchar(255),
|
||||||
|
"role_id" integer,
|
||||||
|
"granted_by" varchar(255) NOT NULL,
|
||||||
|
"permission_level" text DEFAULT 'use' NOT NULL,
|
||||||
|
"expires_at" varchar(255),
|
||||||
|
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "shared_credential_secrets" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"credential_access_id" integer NOT NULL,
|
||||||
|
"target_user_id" varchar(255) NOT NULL,
|
||||||
|
"credential_id" integer NOT NULL,
|
||||||
|
"encrypted_username" text,
|
||||||
|
"auth_type" text DEFAULT 'password' NOT NULL,
|
||||||
|
"encrypted_password" text,
|
||||||
|
"encrypted_key" text,
|
||||||
|
"encrypted_key_password" text,
|
||||||
|
"key_type" text,
|
||||||
|
"public_key" text,
|
||||||
|
"cert_public_key" text,
|
||||||
|
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_credential_access_id_credential_access_id_fk" FOREIGN KEY ("credential_access_id") REFERENCES "public"."credential_access"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_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 INDEX "idx_credential_access_user_id" ON "credential_access" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_credential_access_role_id" ON "credential_access" USING btree ("role_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_credential_access_credential_id" ON "credential_access" USING btree ("credential_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_shared_credential_secrets_scope" ON "shared_credential_secrets" USING btree ("credential_access_id","target_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_shared_credential_secrets_target" ON "shared_credential_secrets" USING btree ("target_user_id","credential_id");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
|||||||
"when": 1787596411353,
|
"when": 1787596411353,
|
||||||
"tag": "0017_curved_pet_avengers",
|
"tag": "0017_curved_pet_avengers",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787600442827,
|
||||||
|
"tag": "0018_puzzling_night_nurse",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE `credential_access` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`credential_id` integer NOT NULL,
|
||||||
|
`user_id` text,
|
||||||
|
`role_id` integer,
|
||||||
|
`granted_by` text NOT NULL,
|
||||||
|
`permission_level` text DEFAULT 'use' NOT NULL,
|
||||||
|
`expires_at` text,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `shared_credential_secrets` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`credential_access_id` integer NOT NULL,
|
||||||
|
`target_user_id` text NOT NULL,
|
||||||
|
`credential_id` integer NOT NULL,
|
||||||
|
`encrypted_username` text,
|
||||||
|
`auth_type` text DEFAULT 'password' NOT NULL,
|
||||||
|
`encrypted_password` text,
|
||||||
|
`encrypted_key` text(16384),
|
||||||
|
`encrypted_key_password` text,
|
||||||
|
`key_type` text,
|
||||||
|
`public_key` text(4096),
|
||||||
|
`cert_public_key` text(8192),
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`target_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 `idx_shared_credential_secrets_scope` ON `shared_credential_secrets` (`credential_access_id`,`target_user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
|||||||
"when": 1787596405780,
|
"when": 1787596405780,
|
||||||
"tag": "0013_tranquil_master_mold",
|
"tag": "0013_tranquil_master_mold",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787600440707,
|
||||||
|
"tag": "0014_lean_doctor_octopus",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -622,6 +622,46 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS credential_access (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
credential_id INTEGER NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
role_id INTEGER,
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'use',
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (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_credential_access_user_id ON credential_access (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_access_role_id ON credential_access (role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_access_credential_id ON credential_access (credential_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shared_credential_secrets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
credential_access_id INTEGER NOT NULL,
|
||||||
|
target_user_id TEXT NOT NULL,
|
||||||
|
credential_id INTEGER NOT NULL,
|
||||||
|
encrypted_username TEXT,
|
||||||
|
auth_type TEXT NOT NULL DEFAULT 'password',
|
||||||
|
encrypted_password TEXT,
|
||||||
|
encrypted_key TEXT,
|
||||||
|
encrypted_key_password TEXT,
|
||||||
|
key_type TEXT,
|
||||||
|
public_key TEXT,
|
||||||
|
cert_public_key TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (credential_access_id, target_user_id),
|
||||||
|
FOREIGN KEY (credential_access_id) REFERENCES credential_access (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shared_credential_secrets_target ON shared_credential_secrets (target_user_id, credential_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -2048,3 +2048,90 @@ export const secretSources = mysqlTable(
|
|||||||
},
|
},
|
||||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- credential sharing ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may use or manage someone else's credential. Same shape as
|
||||||
|
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||||
|
* edits and re-shares it.
|
||||||
|
*/
|
||||||
|
export const credentialAccess = mysqlTable(
|
||||||
|
"credential_access",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
credentialId: int("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
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("use"),
|
||||||
|
|
||||||
|
expiresAt: varchar("expires_at", { length: 255 }),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_credential_access_user_id").on(table.userId),
|
||||||
|
index("idx_credential_access_role_id").on(table.roleId),
|
||||||
|
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||||
|
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||||
|
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||||
|
* recipient, like shared_host_secrets.
|
||||||
|
*/
|
||||||
|
export const sharedCredentialSecrets = mysqlTable(
|
||||||
|
"shared_credential_secrets",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
credentialAccessId: int("credential_access_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => credentialAccess.id, { onDelete: "cascade" }),
|
||||||
|
targetUserId: varchar("target_user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
credentialId: int("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
encryptedUsername: text("encrypted_username"),
|
||||||
|
authType: text("auth_type").notNull().default("password"),
|
||||||
|
encryptedPassword: text("encrypted_password"),
|
||||||
|
encryptedKey: text("encrypted_key"),
|
||||||
|
encryptedKeyPassword: text("encrypted_key_password"),
|
||||||
|
keyType: text("key_type"),
|
||||||
|
publicKey: text("public_key"),
|
||||||
|
certPublicKey: text("cert_public_key"),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
updatedAt: varchar("updated_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
|
table.credentialAccessId,
|
||||||
|
table.targetUserId,
|
||||||
|
),
|
||||||
|
index("idx_shared_credential_secrets_target").on(
|
||||||
|
table.targetUserId,
|
||||||
|
table.credentialId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -2049,3 +2049,90 @@ export const secretSources = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- credential sharing ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may use or manage someone else's credential. Same shape as
|
||||||
|
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||||
|
* edits and re-shares it.
|
||||||
|
*/
|
||||||
|
export const credentialAccess = pgTable(
|
||||||
|
"credential_access",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
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("use"),
|
||||||
|
|
||||||
|
expiresAt: varchar("expires_at", { length: 255 }),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_credential_access_user_id").on(table.userId),
|
||||||
|
index("idx_credential_access_role_id").on(table.roleId),
|
||||||
|
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||||
|
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||||
|
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||||
|
* recipient, like shared_host_secrets.
|
||||||
|
*/
|
||||||
|
export const sharedCredentialSecrets = pgTable(
|
||||||
|
"shared_credential_secrets",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
credentialAccessId: integer("credential_access_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => credentialAccess.id, { onDelete: "cascade" }),
|
||||||
|
targetUserId: varchar("target_user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
encryptedUsername: text("encrypted_username"),
|
||||||
|
authType: text("auth_type").notNull().default("password"),
|
||||||
|
encryptedPassword: text("encrypted_password"),
|
||||||
|
encryptedKey: text("encrypted_key"),
|
||||||
|
encryptedKeyPassword: text("encrypted_key_password"),
|
||||||
|
keyType: text("key_type"),
|
||||||
|
publicKey: text("public_key"),
|
||||||
|
certPublicKey: text("cert_public_key"),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: varchar("updated_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
|
table.credentialAccessId,
|
||||||
|
table.targetUserId,
|
||||||
|
),
|
||||||
|
index("idx_shared_credential_secrets_target").on(
|
||||||
|
table.targetUserId,
|
||||||
|
table.credentialId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -2045,3 +2045,90 @@ export const secretSources = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- credential sharing ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may use or manage someone else's credential. Same shape as
|
||||||
|
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||||
|
* edits and re-shares it.
|
||||||
|
*/
|
||||||
|
export const credentialAccess = sqliteTable(
|
||||||
|
"credential_access",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
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("use"),
|
||||||
|
|
||||||
|
expiresAt: text("expires_at"),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_credential_access_user_id").on(table.userId),
|
||||||
|
index("idx_credential_access_role_id").on(table.roleId),
|
||||||
|
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||||
|
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||||
|
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||||
|
* recipient, like shared_host_secrets.
|
||||||
|
*/
|
||||||
|
export const sharedCredentialSecrets = sqliteTable(
|
||||||
|
"shared_credential_secrets",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
credentialAccessId: integer("credential_access_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => credentialAccess.id, { onDelete: "cascade" }),
|
||||||
|
targetUserId: text("target_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
encryptedUsername: text("encrypted_username"),
|
||||||
|
authType: text("auth_type").notNull().default("password"),
|
||||||
|
encryptedPassword: text("encrypted_password"),
|
||||||
|
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||||
|
encryptedKeyPassword: text("encrypted_key_password"),
|
||||||
|
keyType: text("key_type"),
|
||||||
|
publicKey: text("public_key", { length: 4096 }),
|
||||||
|
certPublicKey: text("cert_public_key", { length: 8192 }),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
|
table.credentialAccessId,
|
||||||
|
table.targetUserId,
|
||||||
|
),
|
||||||
|
index("idx_shared_credential_secrets_target").on(
|
||||||
|
table.targetUserId,
|
||||||
|
table.credentialId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { and, eq, gte, inArray, isNull, or, type SQL } from "drizzle-orm";
|
||||||
|
import { alias } from "drizzle-orm/sqlite-core";
|
||||||
|
import {
|
||||||
|
credentialAccess,
|
||||||
|
roles,
|
||||||
|
sshCredentials,
|
||||||
|
users,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { insertReturning } from "./returning.js";
|
||||||
|
|
||||||
|
export type CredentialAccessRecord = typeof credentialAccess.$inferSelect;
|
||||||
|
export type CredentialPermissionLevel = "use" | "manage";
|
||||||
|
|
||||||
|
export interface CredentialAccessListItem extends CredentialAccessRecord {
|
||||||
|
targetType: "user" | "role";
|
||||||
|
username: string | null;
|
||||||
|
roleName: string | null;
|
||||||
|
roleDisplayName: string | null;
|
||||||
|
grantedByUsername: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CredentialAccessTarget =
|
||||||
|
| { targetType: "user"; targetUserId: string }
|
||||||
|
| { targetType: "role"; targetRoleId: number };
|
||||||
|
|
||||||
|
export interface UpsertCredentialAccessInput {
|
||||||
|
credentialId: number;
|
||||||
|
grantedBy: string;
|
||||||
|
permissionLevel: CredentialPermissionLevel;
|
||||||
|
expiresAt: string | null;
|
||||||
|
target: CredentialAccessTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A credential shared with the caller, with the grant that admits them. */
|
||||||
|
export interface SharedCredentialGrant {
|
||||||
|
accessId: number;
|
||||||
|
credentialId: number;
|
||||||
|
ownerId: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeFilter(now: string): SQL {
|
||||||
|
return or(
|
||||||
|
isNull(credentialAccess.expiresAt),
|
||||||
|
gte(credentialAccess.expiresAt, now),
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function granteeFilter(userId: string, roleIds: number[]): SQL {
|
||||||
|
return roleIds.length === 0
|
||||||
|
? eq(credentialAccess.userId, userId)
|
||||||
|
: or(
|
||||||
|
eq(credentialAccess.userId, userId),
|
||||||
|
inArray(credentialAccess.roleId, roleIds),
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CredentialAccessRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listForCredential(
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<CredentialAccessListItem[]> {
|
||||||
|
const granter = alias(users, "granter");
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
access: credentialAccess,
|
||||||
|
username: users.username,
|
||||||
|
roleName: roles.name,
|
||||||
|
roleDisplayName: roles.displayName,
|
||||||
|
grantedByUsername: granter.username,
|
||||||
|
})
|
||||||
|
.from(credentialAccess)
|
||||||
|
.leftJoin(users, eq(credentialAccess.userId, users.id))
|
||||||
|
.leftJoin(roles, eq(credentialAccess.roleId, roles.id))
|
||||||
|
.leftJoin(granter, eq(credentialAccess.grantedBy, granter.id))
|
||||||
|
.where(eq(credentialAccess.credentialId, credentialId));
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row.access,
|
||||||
|
targetType: row.access.roleId ? ("role" as const) : ("user" as const),
|
||||||
|
username: row.username,
|
||||||
|
roleName: row.roleName,
|
||||||
|
roleDisplayName: row.roleDisplayName,
|
||||||
|
grantedByUsername: row.grantedByUsername,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(
|
||||||
|
input: UpsertCredentialAccessInput,
|
||||||
|
): Promise<{ id: number; created: boolean }> {
|
||||||
|
const targetFilter =
|
||||||
|
input.target.targetType === "user"
|
||||||
|
? eq(credentialAccess.userId, input.target.targetUserId)
|
||||||
|
: eq(credentialAccess.roleId, input.target.targetRoleId);
|
||||||
|
const existing = await this.context.drizzle
|
||||||
|
.select({ id: credentialAccess.id })
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credentialAccess.credentialId, input.credentialId),
|
||||||
|
targetFilter,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(credentialAccess)
|
||||||
|
.set({
|
||||||
|
permissionLevel: input.permissionLevel,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
grantedBy: input.grantedBy,
|
||||||
|
})
|
||||||
|
.where(eq(credentialAccess.id, existing[0].id));
|
||||||
|
await this.afterWrite();
|
||||||
|
return { id: existing[0].id, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await insertReturning(this.context, credentialAccess, {
|
||||||
|
credentialId: input.credentialId,
|
||||||
|
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 { id: created.id, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
accessId: number,
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<CredentialAccessRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credentialAccess.id, accessId),
|
||||||
|
eq(credentialAccess.credentialId, credentialId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(accessId: number, credentialId: number): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credentialAccess.id, accessId),
|
||||||
|
eq(credentialAccess.credentialId, credentialId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The strongest unexpired grant admitting this user to the credential. */
|
||||||
|
async findActiveGrant(
|
||||||
|
credentialId: number,
|
||||||
|
userId: string,
|
||||||
|
roleIds: number[],
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<CredentialAccessRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credentialAccess.credentialId, credentialId),
|
||||||
|
granteeFilter(userId, roleIds),
|
||||||
|
activeFilter(now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
rows.find((row) => row.permissionLevel === "manage") ?? rows[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every credential shared with this user (directly or via a role). */
|
||||||
|
async listSharedWithUser(
|
||||||
|
userId: string,
|
||||||
|
roleIds: number[],
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SharedCredentialGrant[]> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
accessId: credentialAccess.id,
|
||||||
|
credentialId: credentialAccess.credentialId,
|
||||||
|
ownerId: sshCredentials.userId,
|
||||||
|
permissionLevel: credentialAccess.permissionLevel,
|
||||||
|
expiresAt: credentialAccess.expiresAt,
|
||||||
|
})
|
||||||
|
.from(credentialAccess)
|
||||||
|
.innerJoin(
|
||||||
|
sshCredentials,
|
||||||
|
eq(credentialAccess.credentialId, sshCredentials.id),
|
||||||
|
)
|
||||||
|
.where(and(granteeFilter(userId, roleIds), activeFilter(now)));
|
||||||
|
// Direct and role grants can overlap; keep the strongest per credential.
|
||||||
|
const best = new Map<number, SharedCredentialGrant>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.ownerId === userId) continue;
|
||||||
|
const current = best.get(row.credentialId);
|
||||||
|
if (!current || row.permissionLevel === "manage")
|
||||||
|
best.set(row.credentialId, row);
|
||||||
|
}
|
||||||
|
return Array.from(best.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active grants on one credential, expanded to user and role targets. */
|
||||||
|
async listActiveGrants(
|
||||||
|
credentialId: number,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<CredentialAccessRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(eq(credentialAccess.credentialId, credentialId), activeFilter(now)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grants a role holds, with the credential owner - for new-member snapshots. */
|
||||||
|
async listRoleGrants(
|
||||||
|
roleId: number,
|
||||||
|
): Promise<
|
||||||
|
Array<{ accessId: number; credentialId: number; ownerId: string }>
|
||||||
|
> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select({
|
||||||
|
accessId: credentialAccess.id,
|
||||||
|
credentialId: credentialAccess.credentialId,
|
||||||
|
ownerId: sshCredentials.userId,
|
||||||
|
})
|
||||||
|
.from(credentialAccess)
|
||||||
|
.innerJoin(
|
||||||
|
sshCredentials,
|
||||||
|
eq(credentialAccess.credentialId, sshCredentials.id),
|
||||||
|
)
|
||||||
|
.where(eq(credentialAccess.roleId, roleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForUserReferences(userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(credentialAccess)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(credentialAccess.userId, userId),
|
||||||
|
eq(credentialAccess.grantedBy, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reassignGrantedBy(fromUserId: string, toUserId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(credentialAccess)
|
||||||
|
.set({ grantedBy: toUserId })
|
||||||
|
.where(eq(credentialAccess.grantedBy, fromUserId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -348,6 +348,54 @@ export class CredentialRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-keys every credential of one user to another: decrypt under the old
|
||||||
|
* owner's key, encrypt under the new one, change the owner. Used when an
|
||||||
|
* account is deleted and its data is inherited rather than dropped.
|
||||||
|
*/
|
||||||
|
async transferAllToUser(
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const fromKey = DataCrypto.validateUserAccess(fromUserId);
|
||||||
|
const toKey = DataCrypto.validateUserAccess(toUserId);
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, fromUserId));
|
||||||
|
const moved: number[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const plain = DataCrypto.decryptRecord(
|
||||||
|
"ssh_credentials",
|
||||||
|
row,
|
||||||
|
fromUserId,
|
||||||
|
fromKey,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
userId: _userId,
|
||||||
|
...fields
|
||||||
|
} = plain as Record<string, unknown>;
|
||||||
|
const encrypted = DataCrypto.encryptRecord(
|
||||||
|
"ssh_credentials",
|
||||||
|
{ ...fields, id: row.id },
|
||||||
|
toUserId,
|
||||||
|
toKey,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
delete encrypted.id;
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sshCredentials)
|
||||||
|
.set({
|
||||||
|
...(encrypted as Partial<NewCredentialRecord>),
|
||||||
|
userId: toUserId,
|
||||||
|
})
|
||||||
|
.where(eq(sshCredentials.id, row.id));
|
||||||
|
moved.push(row.id);
|
||||||
|
}
|
||||||
|
if (moved.length) await this.afterWrite();
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
|
private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
|
||||||
record: T,
|
record: T,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import { SessionRepository } from "./session-repository.js";
|
|||||||
import { SessionShareRepository } from "./session-share-repository.js";
|
import { SessionShareRepository } from "./session-share-repository.js";
|
||||||
import { CollabRoomRepository } from "./collab-room-repository.js";
|
import { CollabRoomRepository } from "./collab-room-repository.js";
|
||||||
import { SecretSourceRepository } from "./secret-source-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 { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||||
@@ -508,6 +510,22 @@ export function createCurrentUserRepository(): UserRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentCredentialAccessRepository(): CredentialAccessRepository {
|
||||||
|
return new CredentialAccessRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("credential_access_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCurrentSharedCredentialSecretsRepository(): SharedCredentialSecretsRepository {
|
||||||
|
return new SharedCredentialSecretsRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook(
|
||||||
|
"shared_credential_secrets_repository_write",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
|
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
|
||||||
return new SecretSourceRepository(
|
return new SecretSourceRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -168,6 +168,47 @@ export class HostRepository {
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-keys every host of one user to another; see CredentialRepository.transferAllToUser. */
|
||||||
|
async transferAllToUser(
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const fromKey = DataCrypto.validateUserAccess(fromUserId);
|
||||||
|
const toKey = DataCrypto.validateUserAccess(toUserId);
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.userId, fromUserId));
|
||||||
|
const moved: number[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const plain = DataCrypto.decryptRecord(
|
||||||
|
"ssh_data",
|
||||||
|
row,
|
||||||
|
fromUserId,
|
||||||
|
fromKey,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
userId: _userId,
|
||||||
|
...fields
|
||||||
|
} = plain as Record<string, unknown>;
|
||||||
|
const encrypted = DataCrypto.encryptRecord(
|
||||||
|
"ssh_data",
|
||||||
|
{ ...fields, id: row.id },
|
||||||
|
toUserId,
|
||||||
|
toKey,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
delete encrypted.id;
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(hosts)
|
||||||
|
.set({ ...(encrypted as Partial<HostUpdate>), userId: toUserId })
|
||||||
|
.where(eq(hosts.id, row.id));
|
||||||
|
moved.push(row.id);
|
||||||
|
}
|
||||||
|
if (moved.length) await this.afterWrite();
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
async updateEncryptedForUser(
|
async updateEncryptedForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
|
|||||||
@@ -207,6 +207,18 @@ export class RbacAccessRepository {
|
|||||||
return rowsAffected(result);
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grants the departing user handed out now count as the successor's. */
|
||||||
|
async reassignHostAccessGrantedBy(
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(hostAccess)
|
||||||
|
.set({ grantedBy: toUserId })
|
||||||
|
.where(eq(hostAccess.grantedBy, fromUserId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
|
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
|
||||||
const directResult = await this.context.drizzle
|
const directResult = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { credentialAccess, sharedCredentialSecrets } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
|
export type SharedCredentialSecretRecord =
|
||||||
|
typeof sharedCredentialSecrets.$inferSelect;
|
||||||
|
export type NewSharedCredentialSecretRecord =
|
||||||
|
typeof sharedCredentialSecrets.$inferInsert;
|
||||||
|
|
||||||
|
export class SharedCredentialSecretsRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upsert(record: NewSharedCredentialSecretRecord): Promise<void> {
|
||||||
|
const existing = await this.context.drizzle
|
||||||
|
.select({ id: sharedCredentialSecrets.id })
|
||||||
|
.from(sharedCredentialSecrets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
sharedCredentialSecrets.credentialAccessId,
|
||||||
|
record.credentialAccessId,
|
||||||
|
),
|
||||||
|
eq(sharedCredentialSecrets.targetUserId, record.targetUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (existing[0]) {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sharedCredentialSecrets)
|
||||||
|
.set({ ...record, updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(sharedCredentialSecrets.id, existing[0].id));
|
||||||
|
} else {
|
||||||
|
await this.context.drizzle.insert(sharedCredentialSecrets).values(record);
|
||||||
|
}
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recipient's snapshot for a credential, whichever grant produced it. */
|
||||||
|
async findForCredentialUser(
|
||||||
|
credentialId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<SharedCredentialSecretRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sharedCredentialSecrets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedCredentialSecrets.credentialId, credentialId),
|
||||||
|
eq(sharedCredentialSecrets.targetUserId, targetUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForRoleMember(
|
||||||
|
roleId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const grants = await this.context.drizzle
|
||||||
|
.select({ id: credentialAccess.id })
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(eq(credentialAccess.roleId, roleId));
|
||||||
|
if (grants.length === 0) return;
|
||||||
|
await this.context.drizzle.delete(sharedCredentialSecrets).where(
|
||||||
|
and(
|
||||||
|
inArray(
|
||||||
|
sharedCredentialSecrets.credentialAccessId,
|
||||||
|
grants.map((g) => g.id),
|
||||||
|
),
|
||||||
|
eq(sharedCredentialSecrets.targetUserId, targetUserId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByTargetUserId(userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(sharedCredentialSecrets)
|
||||||
|
.where(eq(sharedCredentialSecrets.targetUserId, userId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
} from "../../utils/audit-logger.js";
|
} from "../../utils/audit-logger.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
|
createCurrentUserRepository,
|
||||||
|
createCurrentCredentialAccessRepository,
|
||||||
|
createCurrentRoleRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentSyncTombstoneRepository,
|
createCurrentSyncTombstoneRepository,
|
||||||
@@ -266,8 +269,11 @@ router.get(
|
|||||||
try {
|
try {
|
||||||
const credentials =
|
const credentials =
|
||||||
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
||||||
|
const own = credentials.map((cred) => formatCredentialOutput(cred));
|
||||||
|
|
||||||
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
|
// Credentials shared with this user, read from their own snapshots.
|
||||||
|
const shared = await listSharedCredentialsForUser(userId);
|
||||||
|
res.json([...own, ...shared]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to fetch credentials", err);
|
authLogger.error("Failed to fetch credentials", err);
|
||||||
res.status(500).json({ error: "Failed to fetch credentials" });
|
res.status(500).json({ error: "Failed to fetch credentials" });
|
||||||
@@ -362,11 +368,14 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credential =
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
const ownCredential = await credentialRepository.findDecryptedByIdForUser(
|
||||||
userId,
|
userId,
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
);
|
);
|
||||||
|
const credential =
|
||||||
|
ownCredential ??
|
||||||
|
(await findSharedCredentialForUser(parseInt(id), userId));
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
return res.status(404).json({ error: "Credential not found" });
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
@@ -617,13 +626,21 @@ router.put(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingCredential =
|
// A recipient holding "manage" edits the owner's row on the owner's
|
||||||
await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
// behalf; the row stays encrypted under the owner's key and every
|
||||||
userId,
|
// recipient's snapshot is rebuilt below.
|
||||||
|
const editableOwnerId = await resolveEditableCredentialOwner(
|
||||||
credentialId,
|
credentialId,
|
||||||
|
userId,
|
||||||
);
|
);
|
||||||
|
const existingCredential = editableOwnerId
|
||||||
|
? await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
||||||
|
editableOwnerId,
|
||||||
|
credentialId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!existingCredential) {
|
if (!existingCredential || !editableOwnerId) {
|
||||||
return res.status(404).json({ error: "Credential not found" });
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,14 +703,14 @@ router.put(
|
|||||||
|
|
||||||
const credentialRepository = createCurrentCredentialRepository();
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
const updated = await credentialRepository.updateEncryptedForUser(
|
const updated = await credentialRepository.updateEncryptedForUser(
|
||||||
userId,
|
editableOwnerId,
|
||||||
credentialId,
|
credentialId,
|
||||||
updateFields,
|
updateFields,
|
||||||
);
|
);
|
||||||
const updatedCredential =
|
const updatedCredential =
|
||||||
updated ??
|
updated ??
|
||||||
(await credentialRepository.findDecryptedByIdForUser(
|
(await credentialRepository.findDecryptedByIdForUser(
|
||||||
userId,
|
editableOwnerId,
|
||||||
credentialId,
|
credentialId,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -701,7 +718,13 @@ router.put(
|
|||||||
await import("../../utils/shared-host-secrets-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
|
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
|
||||||
credentialId,
|
credentialId,
|
||||||
userId,
|
editableOwnerId,
|
||||||
|
);
|
||||||
|
const { SharedCredentialSecretsManager } =
|
||||||
|
await import("../../utils/shared-credential-secrets-manager.js");
|
||||||
|
await SharedCredentialSecretsManager.getInstance().resyncCredential(
|
||||||
|
credentialId,
|
||||||
|
editableOwnerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
authLogger.success("SSH credential updated", {
|
authLogger.success("SSH credential updated", {
|
||||||
@@ -1002,6 +1025,78 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The owner id if the caller may edit this credential (owner or "manage"), else null. */
|
||||||
|
async function resolveEditableCredentialOwner(
|
||||||
|
credentialId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const row = await createCurrentCredentialRepository().findById(credentialId);
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.userId === userId) return userId;
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
|
||||||
|
credentialId,
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
);
|
||||||
|
return grant?.permissionLevel === "manage" ? row.userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSharedCredentialForUser(
|
||||||
|
credentialId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const shared = (await listSharedCredentialsForUser(userId)).find(
|
||||||
|
(cred) => cred.id === credentialId,
|
||||||
|
);
|
||||||
|
return shared ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared credentials shaped like the caller's own, plus who shared them. */
|
||||||
|
async function listSharedCredentialsForUser(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
const grants =
|
||||||
|
await createCurrentCredentialAccessRepository().listSharedWithUser(
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
);
|
||||||
|
if (grants.length === 0) return [];
|
||||||
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const { findUsableCredential } =
|
||||||
|
await import("../../hosts/usable-credential.js");
|
||||||
|
const results: Record<string, unknown>[] = [];
|
||||||
|
for (const grant of grants) {
|
||||||
|
const row = await credentialRepository.findById(grant.credentialId);
|
||||||
|
if (!row) continue;
|
||||||
|
let secrets: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
secrets = (await findUsableCredential(
|
||||||
|
grant.credentialId,
|
||||||
|
userId,
|
||||||
|
)) as Record<string, unknown> | null;
|
||||||
|
} catch {
|
||||||
|
secrets = null;
|
||||||
|
}
|
||||||
|
const owner = await userRepository.findById(grant.ownerId);
|
||||||
|
results.push({
|
||||||
|
...formatCredentialOutput({
|
||||||
|
...row,
|
||||||
|
username: secrets?.username ?? row.username,
|
||||||
|
publicKey: secrets?.publicKey ?? null,
|
||||||
|
certPublicKey: secrets?.certPublicKey ?? null,
|
||||||
|
}),
|
||||||
|
isShared: true,
|
||||||
|
ownerUsername: owner?.username ?? null,
|
||||||
|
permissionLevel: grant.permissionLevel,
|
||||||
|
sharedExpiresAt: grant.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCredentialOutput(
|
function formatCredentialOutput(
|
||||||
credential: Record<string, unknown>,
|
credential: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
|||||||
@@ -40,14 +40,33 @@ import {
|
|||||||
createCurrentTransferRecentRepository,
|
createCurrentTransferRecentRepository,
|
||||||
createCurrentVaultProfileRepository,
|
createCurrentVaultProfileRepository,
|
||||||
createCurrentSecretSourceRepository,
|
createCurrentSecretSourceRepository,
|
||||||
|
createCurrentSharedCredentialSecretsRepository,
|
||||||
|
createCurrentCredentialAccessRepository,
|
||||||
createCurrentVaultTokenRepository,
|
createCurrentVaultTokenRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
|
||||||
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
export async function deleteUserAndRelatedData(
|
||||||
|
userId: string,
|
||||||
|
options: { successorUserId?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// With a successor, hosts and credentials (and the shares on them)
|
||||||
|
// change owner instead of disappearing with the account.
|
||||||
|
if (options.successorUserId) {
|
||||||
|
const { transferOwnership } =
|
||||||
|
await import("../../utils/transfer-ownership.js");
|
||||||
|
await transferOwnership(userId, options.successorUserId);
|
||||||
|
}
|
||||||
|
|
||||||
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
await createCurrentSharedCredentialSecretsRepository().deleteByTargetUserId(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
await createCurrentCredentialAccessRepository().deleteForUserReferences(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
// Retained rather than deleted: these outlive the account by design.
|
// Retained rather than deleted: these outlive the account by design.
|
||||||
// See anonymizeByUserId on each repository.
|
// See anonymizeByUserId on each repository.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -716,11 +717,7 @@ router.post(
|
|||||||
let resolvedUsername = username;
|
let resolvedUsername = username;
|
||||||
|
|
||||||
if (authType === "credential" && credentialId) {
|
if (authType === "credential" && credentialId) {
|
||||||
const cred =
|
const cred = await findUsableCredential(Number(credentialId), userId);
|
||||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
|
||||||
Number(credentialId),
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!cred) {
|
if (!cred) {
|
||||||
return res.status(404).json({ error: "Credential not found" });
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
@@ -2693,10 +2690,7 @@ async function resolveHostCredentials(
|
|||||||
|
|
||||||
const credential =
|
const credential =
|
||||||
preloadedCredentials?.get(credentialId) ??
|
preloadedCredentials?.get(credentialId) ??
|
||||||
(await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
(await findUsableCredential(credentialId, credentialOwnerId));
|
||||||
credentialId,
|
|
||||||
credentialOwnerId,
|
|
||||||
));
|
|
||||||
|
|
||||||
if (credential) {
|
if (credential) {
|
||||||
const resolvedHost: Record<string, unknown> = {
|
const resolvedHost: Record<string, unknown> = {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import express, { type Response } from "express";
|
import express, { type Response } from "express";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { getRequestMeta } from "../../utils/audit-logger.js";
|
import {
|
||||||
|
getRequestMeta,
|
||||||
|
getAuditUsername,
|
||||||
|
logAudit,
|
||||||
|
} from "../../utils/audit-logger.js";
|
||||||
import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||||
import {
|
import {
|
||||||
SharedHostAuthOverrideService,
|
SharedHostAuthOverrideService,
|
||||||
@@ -20,6 +24,8 @@ import {
|
|||||||
} from "../../utils/permission-catalog.js";
|
} from "../../utils/permission-catalog.js";
|
||||||
import {
|
import {
|
||||||
createCurrentHostFolderRepository,
|
createCurrentHostFolderRepository,
|
||||||
|
createCurrentCredentialRepository,
|
||||||
|
createCurrentCredentialAccessRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
@@ -1362,6 +1368,12 @@ router.post(
|
|||||||
roleId,
|
roleId,
|
||||||
targetUserId,
|
targetUserId,
|
||||||
);
|
);
|
||||||
|
const { SharedCredentialSecretsManager } =
|
||||||
|
await import("../../utils/shared-credential-secrets-manager.js");
|
||||||
|
await SharedCredentialSecretsManager.getInstance().snapshotForRoleMember(
|
||||||
|
roleId,
|
||||||
|
targetUserId,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
"Failed to snapshot shared host secrets for new role member",
|
"Failed to snapshot shared host secrets for new role member",
|
||||||
@@ -1471,6 +1483,12 @@ router.delete(
|
|||||||
roleId,
|
roleId,
|
||||||
targetUserId,
|
targetUserId,
|
||||||
);
|
);
|
||||||
|
const { createCurrentSharedCredentialSecretsRepository } =
|
||||||
|
await import("../repositories/factory.js");
|
||||||
|
await createCurrentSharedCredentialSecretsRepository().deleteForRoleMember(
|
||||||
|
roleId,
|
||||||
|
targetUserId,
|
||||||
|
);
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
databaseLogger.warn(
|
databaseLogger.warn(
|
||||||
"Failed to clean shared host secrets after role removal",
|
"Failed to clean shared host secrets after role removal",
|
||||||
@@ -1559,6 +1577,239 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CREDENTIAL SHARING
|
||||||
|
|
||||||
|
const CREDENTIAL_LEVELS = ["use", "manage"] as const;
|
||||||
|
type CredentialLevel = (typeof CREDENTIAL_LEVELS)[number];
|
||||||
|
|
||||||
|
/** Owner, or a recipient holding "manage". */
|
||||||
|
async function canManageCredentialSharing(
|
||||||
|
userId: string,
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<{ allowed: boolean; ownerId: string | null }> {
|
||||||
|
const row = await createCurrentCredentialRepository().findById(credentialId);
|
||||||
|
if (!row) return { allowed: false, ownerId: null };
|
||||||
|
if (row.userId === userId) return { allowed: true, ownerId: row.userId };
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
|
||||||
|
credentialId,
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
);
|
||||||
|
return { allowed: grant?.permissionLevel === "manage", ownerId: row.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/credential/{id}/share:
|
||||||
|
* post:
|
||||||
|
* summary: Share a credential with users or roles
|
||||||
|
* description: Recipients get a copy of the secrets re-encrypted under their own key. "use" lets them attach it to hosts and connect; "manage" also lets them edit and re-share. Owner or a "manage" recipient only.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/credential/:id/share",
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.share"),
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
|
const credentialId = parseInt(id, 10);
|
||||||
|
const userId = req.userId!;
|
||||||
|
if (isNaN(credentialId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid credential ID" });
|
||||||
|
}
|
||||||
|
const targets = parseShareTargets(req.body ?? {});
|
||||||
|
if (!targets) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { durationHours, permissionLevel = "use" } = req.body ?? {};
|
||||||
|
if (!CREDENTIAL_LEVELS.includes(permissionLevel)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid permission level",
|
||||||
|
validLevels: CREDENTIAL_LEVELS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sharing = await canManageCredentialSharing(userId, credentialId);
|
||||||
|
if (!sharing.allowed || !sharing.ownerId) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Not allowed to share this credential" });
|
||||||
|
}
|
||||||
|
const ownerId = sharing.ownerId;
|
||||||
|
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const roleRepository = createCurrentRoleRepository();
|
||||||
|
for (const target of targets) {
|
||||||
|
const found =
|
||||||
|
target.type === "user"
|
||||||
|
? await userRepository.findById(target.id as string)
|
||||||
|
: await roleRepository.findRoleById(target.id as number);
|
||||||
|
if (!found) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Target ${target.type} not found`,
|
||||||
|
targetId: target.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = expiryFromDuration(durationHours);
|
||||||
|
const accessRepository = createCurrentCredentialAccessRepository();
|
||||||
|
const { SharedCredentialSecretsManager } =
|
||||||
|
await import("../../utils/shared-credential-secrets-manager.js");
|
||||||
|
const manager = SharedCredentialSecretsManager.getInstance();
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (target.type === "user" && target.id === ownerId) continue;
|
||||||
|
const grant = await accessRepository.upsert({
|
||||||
|
credentialId,
|
||||||
|
grantedBy: userId,
|
||||||
|
permissionLevel: permissionLevel as CredentialLevel,
|
||||||
|
expiresAt,
|
||||||
|
target:
|
||||||
|
target.type === "user"
|
||||||
|
? { targetType: "user", targetUserId: target.id as string }
|
||||||
|
: { targetType: "role", targetRoleId: target.id as number },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (target.type === "user") {
|
||||||
|
await manager.snapshotForUser(
|
||||||
|
grant.id,
|
||||||
|
credentialId,
|
||||||
|
target.id as string,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await manager.snapshotForRole(
|
||||||
|
grant.id,
|
||||||
|
credentialId,
|
||||||
|
target.id as number,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (snapshotError) {
|
||||||
|
databaseLogger.warn("Credential shared but secret snapshot failed", {
|
||||||
|
operation: "rbac_credential_share_snapshot_failed",
|
||||||
|
credentialId,
|
||||||
|
accessId: grant.id,
|
||||||
|
error: getErrorMessage(snapshotError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: await getAuditUsername(userId),
|
||||||
|
action: "credential_share",
|
||||||
|
resourceType: "credential",
|
||||||
|
resourceId: String(credentialId),
|
||||||
|
details: JSON.stringify({ targets, permissionLevel, expiresAt }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, permissionLevel, expiresAt });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to share credential", error, {
|
||||||
|
operation: "share_credential",
|
||||||
|
credentialId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to share credential" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/credential/{id}/access:
|
||||||
|
* get:
|
||||||
|
* summary: List who a credential is shared with
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/credential/:id/access",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
|
const credentialId = parseInt(id, 10);
|
||||||
|
if (isNaN(credentialId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid credential ID" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sharing = await canManageCredentialSharing(
|
||||||
|
req.userId!,
|
||||||
|
credentialId,
|
||||||
|
);
|
||||||
|
if (!sharing.allowed) {
|
||||||
|
return res.status(403).json({ error: "Not allowed" });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
access:
|
||||||
|
await createCurrentCredentialAccessRepository().listForCredential(
|
||||||
|
credentialId,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to list credential access", error, {
|
||||||
|
operation: "list_credential_access",
|
||||||
|
credentialId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to list credential access" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/credential/{id}/access/{accessId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Revoke a credential share (recipients' copies are removed with it)
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/credential/:id/access/:accessId",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const credentialId = parseInt(String(req.params.id), 10);
|
||||||
|
const accessId = parseInt(String(req.params.accessId), 10);
|
||||||
|
if (isNaN(credentialId) || isNaN(accessId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid ID" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sharing = await canManageCredentialSharing(
|
||||||
|
req.userId!,
|
||||||
|
credentialId,
|
||||||
|
);
|
||||||
|
if (!sharing.allowed) {
|
||||||
|
return res.status(403).json({ error: "Not allowed" });
|
||||||
|
}
|
||||||
|
const repository = createCurrentCredentialAccessRepository();
|
||||||
|
if (!(await repository.findById(accessId, credentialId))) {
|
||||||
|
return res.status(404).json({ error: "Access grant not found" });
|
||||||
|
}
|
||||||
|
await repository.revoke(accessId, credentialId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to revoke credential access", error, {
|
||||||
|
operation: "revoke_credential_access",
|
||||||
|
credentialId,
|
||||||
|
accessId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to revoke credential access" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// SNIPPET SHARING
|
// SNIPPET SHARING
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ async function syncSharedCredentialsForUserRoles(
|
|||||||
const { SharedHostSecretsManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-host-secrets-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
|
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
|
||||||
|
const { SharedCredentialSecretsManager } =
|
||||||
|
await import("../../utils/shared-credential-secrets-manager.js");
|
||||||
|
await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authLogger.warn("Failed to sync role shared host secrets", {
|
authLogger.warn("Failed to sync role shared host secrets", {
|
||||||
operation,
|
operation,
|
||||||
@@ -2980,7 +2985,21 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
|||||||
|
|
||||||
const targetUserId = targetUser.id;
|
const targetUserId = targetUser.id;
|
||||||
|
|
||||||
await deleteUserAndRelatedData(targetUserId);
|
// Inherit rather than drop: the deleting admin takes over the hosts and
|
||||||
|
// credentials unless another successor is named; "none" discards them.
|
||||||
|
const { successorUserId: requestedSuccessor } = req.body ?? {};
|
||||||
|
let successorUserId: string | undefined = userId;
|
||||||
|
if (requestedSuccessor === "none") {
|
||||||
|
successorUserId = undefined;
|
||||||
|
} else if (isNonEmptyString(requestedSuccessor)) {
|
||||||
|
const successor = await userRepository.findById(requestedSuccessor);
|
||||||
|
if (!successor || successor.id === targetUserId) {
|
||||||
|
return res.status(400).json({ error: "Invalid successor user" });
|
||||||
|
}
|
||||||
|
successorUserId = successor.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteUserAndRelatedData(targetUserId, { successorUserId });
|
||||||
|
|
||||||
authLogger.warn("User account deleted by admin", {
|
authLogger.warn("User account deleted by admin", {
|
||||||
operation: "admin_delete_user",
|
operation: "admin_delete_user",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getErrorMessage } from "../utils/error-message.js";
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
|
import { findUsableCredential } from "./usable-credential.js";
|
||||||
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
||||||
import {
|
import {
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
@@ -190,7 +191,7 @@ export async function resolveHostById(
|
|||||||
|
|
||||||
if (effectiveCredentialId) {
|
if (effectiveCredentialId) {
|
||||||
try {
|
try {
|
||||||
const cred = (await repository.findCredentialByIdForUser(
|
const cred = (await findUsableCredential(
|
||||||
effectiveCredentialId,
|
effectiveCredentialId,
|
||||||
ownerId,
|
ownerId,
|
||||||
)) as Record<string, unknown> | null;
|
)) as Record<string, unknown> | null;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
createCurrentCredentialAccessRepository,
|
||||||
|
createCurrentCredentialRepository,
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentRoleRepository,
|
||||||
|
} from "../database/repositories/factory.js";
|
||||||
|
import type { HostResolutionCredentialRecord } from "../database/repositories/host-resolution-repository.js";
|
||||||
|
import {
|
||||||
|
SharedCredentialSecretsManager,
|
||||||
|
snapshotAsCredentialRecord,
|
||||||
|
} from "../utils/shared-credential-secrets-manager.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A credential the user may use: their own, or one shared with them (read
|
||||||
|
* from their re-encrypted snapshot). This is the one gate that replaces
|
||||||
|
* "credentials are a private namespace" - every place that turned a
|
||||||
|
* credentialId into secrets goes through here.
|
||||||
|
*/
|
||||||
|
export async function findUsableCredential(
|
||||||
|
credentialId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<HostResolutionCredentialRecord | null> {
|
||||||
|
const own =
|
||||||
|
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||||
|
credentialId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (own) return own;
|
||||||
|
|
||||||
|
const row = await createCurrentCredentialRepository().findById(credentialId);
|
||||||
|
if (!row || row.userId === userId) return null;
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
|
||||||
|
credentialId,
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
);
|
||||||
|
if (!grant) return null;
|
||||||
|
|
||||||
|
const manager = SharedCredentialSecretsManager.getInstance();
|
||||||
|
let data = await manager.getSecretForUser(credentialId, userId);
|
||||||
|
if (!data) {
|
||||||
|
// No snapshot yet (granted while this user's key was unavailable).
|
||||||
|
await manager.snapshotForUser(grant.id, credentialId, userId, row.userId);
|
||||||
|
data = await manager.getSecretForUser(credentialId, userId);
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
? snapshotAsCredentialRecord(credentialId, row.userId, data)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
own: new Map<string, Record<string, unknown>>(), // `${userId}:${id}`
|
||||||
|
rows: new Map<number, { id: number; userId: string }>(),
|
||||||
|
grants: new Map<string, { id: number; permissionLevel: string }>(), // `${id}:${userId}`
|
||||||
|
snapshots: new Map<string, Record<string, unknown>>(), // `${id}:${userId}`
|
||||||
|
snapshotCalls: [] as unknown[][],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
findCredentialByIdForUser: async (id: number, userId: string) =>
|
||||||
|
state.own.get(`${userId}:${id}`) ?? null,
|
||||||
|
}),
|
||||||
|
createCurrentCredentialRepository: () => ({
|
||||||
|
findById: async (id: number) => state.rows.get(id) ?? null,
|
||||||
|
}),
|
||||||
|
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
|
||||||
|
createCurrentCredentialAccessRepository: () => ({
|
||||||
|
findActiveGrant: async (id: number, userId: string) =>
|
||||||
|
state.grants.get(`${id}:${userId}`) ?? null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/shared-credential-secrets-manager.js", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("../../utils/shared-credential-secrets-manager.js")
|
||||||
|
>("../../utils/shared-credential-secrets-manager.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
SharedCredentialSecretsManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getSecretForUser: async (id: number, userId: string) =>
|
||||||
|
state.snapshots.get(`${id}:${userId}`) ?? null,
|
||||||
|
snapshotForUser: async (...args: unknown[]) => {
|
||||||
|
state.snapshotCalls.push(args);
|
||||||
|
const [, id, userId] = args as [number, number, string];
|
||||||
|
state.snapshots.set(`${id}:${userId}`, {
|
||||||
|
authType: "password",
|
||||||
|
username: "svc",
|
||||||
|
password: "pw",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { findUsableCredential } =
|
||||||
|
await import("../../hosts/usable-credential.js");
|
||||||
|
|
||||||
|
describe("findUsableCredential", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
state.own.clear();
|
||||||
|
state.rows.clear();
|
||||||
|
state.grants.clear();
|
||||||
|
state.snapshots.clear();
|
||||||
|
state.snapshotCalls.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the user's own credential first", async () => {
|
||||||
|
state.own.set("alice:1", { id: 1, userId: "alice", password: "mine" });
|
||||||
|
expect(await findUsableCredential(1, "alice")).toMatchObject({
|
||||||
|
password: "mine",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a shared credential from the recipient's snapshot, shaped like a row", async () => {
|
||||||
|
state.rows.set(2, { id: 2, userId: "owner" });
|
||||||
|
state.grants.set("2:bob", { id: 10, permissionLevel: "use" });
|
||||||
|
state.snapshots.set("2:bob", {
|
||||||
|
authType: "key",
|
||||||
|
username: "deploy",
|
||||||
|
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||||
|
keyType: "ssh-ed25519",
|
||||||
|
});
|
||||||
|
const cred = await findUsableCredential(2, "bob");
|
||||||
|
expect(cred).toMatchObject({
|
||||||
|
id: 2,
|
||||||
|
userId: "owner",
|
||||||
|
username: "deploy",
|
||||||
|
authType: "key",
|
||||||
|
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||||
|
key: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the snapshot on demand when a grant exists but no copy yet", async () => {
|
||||||
|
state.rows.set(3, { id: 3, userId: "owner" });
|
||||||
|
state.grants.set("3:bob", { id: 11, permissionLevel: "use" });
|
||||||
|
const cred = await findUsableCredential(3, "bob");
|
||||||
|
expect(state.snapshotCalls).toEqual([[11, 3, "bob", "owner"]]);
|
||||||
|
expect(cred).toMatchObject({ username: "svc", password: "pw" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses credentials that are neither owned nor shared", async () => {
|
||||||
|
state.rows.set(4, { id: 4, userId: "owner" });
|
||||||
|
expect(await findUsableCredential(4, "mallory")).toBeNull();
|
||||||
|
expect(await findUsableCredential(99, "mallory")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
|||||||
"credentials.create",
|
"credentials.create",
|
||||||
"credentials.edit",
|
"credentials.edit",
|
||||||
"credentials.delete",
|
"credentials.delete",
|
||||||
|
"credentials.share",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
|
import { FieldCrypto } from "./field-crypto.js";
|
||||||
|
import { databaseLogger } from "./logger.js";
|
||||||
|
import {
|
||||||
|
createCurrentCredentialAccessRepository,
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentRoleRepository,
|
||||||
|
createCurrentSharedCredentialSecretsRepository,
|
||||||
|
} from "../database/repositories/factory.js";
|
||||||
|
import type { HostResolutionCredentialRecord } from "../database/repositories/host-resolution-repository.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps recipients' re-encrypted copies of shared credentials in step with
|
||||||
|
* the owner's row. Mirrors SharedHostSecretsManager: snapshots are taken when
|
||||||
|
* a share is created, when someone joins a role that holds one, on login as a
|
||||||
|
* backstop, and rebuilt whenever the owner edits the credential.
|
||||||
|
*/
|
||||||
|
function recordId(accessId: number, targetUserId: string): string {
|
||||||
|
return `shared-credential-${accessId}-${targetUserId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedCredentialData {
|
||||||
|
username?: string;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
publicKey?: string;
|
||||||
|
certPublicKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SharedCredentialSecretsManager {
|
||||||
|
private static instance: SharedCredentialSecretsManager;
|
||||||
|
|
||||||
|
static getInstance(): SharedCredentialSecretsManager {
|
||||||
|
if (!this.instance) this.instance = new SharedCredentialSecretsManager();
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotForUser(
|
||||||
|
accessId: number,
|
||||||
|
credentialId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
ownerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (targetUserId === ownerId) return;
|
||||||
|
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
|
||||||
|
DataCrypto.validateUserAccess(ownerId);
|
||||||
|
|
||||||
|
const credential =
|
||||||
|
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||||
|
credentialId,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
if (!credential) throw new Error(`Credential ${credentialId} not found`);
|
||||||
|
|
||||||
|
const id = recordId(accessId, targetUserId);
|
||||||
|
const encrypt = (value: string | null | undefined, field: string) =>
|
||||||
|
value ? FieldCrypto.encryptField(value, targetDEK, id, field) : null;
|
||||||
|
|
||||||
|
await createCurrentSharedCredentialSecretsRepository().upsert({
|
||||||
|
credentialAccessId: accessId,
|
||||||
|
targetUserId,
|
||||||
|
credentialId,
|
||||||
|
encryptedUsername: encrypt(credential.username, "username"),
|
||||||
|
authType: credential.authType,
|
||||||
|
encryptedPassword: encrypt(credential.password, "password"),
|
||||||
|
encryptedKey: encrypt(credential.privateKey || credential.key, "key"),
|
||||||
|
encryptedKeyPassword: encrypt(credential.keyPassword, "key_password"),
|
||||||
|
keyType: credential.keyType ?? null,
|
||||||
|
publicKey: credential.publicKey ?? null,
|
||||||
|
certPublicKey: credential.certPublicKey ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotForRole(
|
||||||
|
accessId: number,
|
||||||
|
credentialId: number,
|
||||||
|
roleId: number,
|
||||||
|
ownerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const members = await createCurrentRoleRepository().listRoleUserIds(roleId);
|
||||||
|
for (const memberId of members) {
|
||||||
|
try {
|
||||||
|
await this.snapshotForUser(accessId, credentialId, memberId, ownerId);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn(
|
||||||
|
"Failed to snapshot shared credential for role member",
|
||||||
|
{
|
||||||
|
operation: "shared_credential_snapshot_role_member",
|
||||||
|
accessId,
|
||||||
|
memberId,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A user just joined a role: give them every credential the role holds. */
|
||||||
|
async snapshotForRoleMember(
|
||||||
|
roleId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const grants =
|
||||||
|
await createCurrentCredentialAccessRepository().listRoleGrants(roleId);
|
||||||
|
for (const grant of grants) {
|
||||||
|
try {
|
||||||
|
await this.snapshotForUser(
|
||||||
|
grant.accessId,
|
||||||
|
grant.credentialId,
|
||||||
|
targetUserId,
|
||||||
|
grant.ownerId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn(
|
||||||
|
"Failed to snapshot shared credential for new role member",
|
||||||
|
{
|
||||||
|
operation: "shared_credential_snapshot_new_member",
|
||||||
|
accessId: grant.accessId,
|
||||||
|
targetUserId,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login backstop: every role the user holds. */
|
||||||
|
async snapshotForUserRoles(userId: string): Promise<void> {
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
for (const roleId of roleIds) {
|
||||||
|
await this.snapshotForRoleMember(roleId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner changed the credential: rebuild every recipient's copy. */
|
||||||
|
async resyncCredential(credentialId: number, ownerId: string): Promise<void> {
|
||||||
|
const accessRepository = createCurrentCredentialAccessRepository();
|
||||||
|
const roleRepository = createCurrentRoleRepository();
|
||||||
|
for (const grant of await accessRepository.listActiveGrants(credentialId)) {
|
||||||
|
const targets = grant.userId
|
||||||
|
? [grant.userId]
|
||||||
|
: grant.roleId
|
||||||
|
? await roleRepository.listRoleUserIds(grant.roleId)
|
||||||
|
: [];
|
||||||
|
for (const targetUserId of targets) {
|
||||||
|
try {
|
||||||
|
await this.snapshotForUser(
|
||||||
|
grant.id,
|
||||||
|
credentialId,
|
||||||
|
targetUserId,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Failed to resync shared credential", {
|
||||||
|
operation: "shared_credential_resync",
|
||||||
|
credentialId,
|
||||||
|
targetUserId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recipient's decrypted copy, if any. */
|
||||||
|
async getSecretForUser(
|
||||||
|
credentialId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<SharedCredentialData | null> {
|
||||||
|
const secret =
|
||||||
|
await createCurrentSharedCredentialSecretsRepository().findForCredentialUser(
|
||||||
|
credentialId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (!secret) return null;
|
||||||
|
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||||
|
if (!userDEK) return null;
|
||||||
|
const id = recordId(secret.credentialAccessId, secret.targetUserId);
|
||||||
|
const decrypt = (value: string | null, field: string) =>
|
||||||
|
value ? FieldCrypto.decryptField(value, userDEK, id, field) : undefined;
|
||||||
|
return {
|
||||||
|
username: decrypt(secret.encryptedUsername, "username"),
|
||||||
|
authType: secret.authType,
|
||||||
|
password: decrypt(secret.encryptedPassword, "password"),
|
||||||
|
privateKey: decrypt(secret.encryptedKey, "key"),
|
||||||
|
keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"),
|
||||||
|
keyType: secret.keyType ?? undefined,
|
||||||
|
publicKey: secret.publicKey ?? undefined,
|
||||||
|
certPublicKey: secret.certPublicKey ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shapes a recipient's snapshot like the owner's decrypted credential row. */
|
||||||
|
export function snapshotAsCredentialRecord(
|
||||||
|
credentialId: number,
|
||||||
|
ownerId: string,
|
||||||
|
data: SharedCredentialData,
|
||||||
|
): HostResolutionCredentialRecord {
|
||||||
|
return {
|
||||||
|
id: credentialId,
|
||||||
|
userId: ownerId,
|
||||||
|
username: data.username ?? null,
|
||||||
|
authType: data.authType,
|
||||||
|
password: data.password ?? null,
|
||||||
|
key: null,
|
||||||
|
privateKey: data.privateKey ?? null,
|
||||||
|
keyPassword: data.keyPassword ?? null,
|
||||||
|
keyType: data.keyType ?? null,
|
||||||
|
publicKey: data.publicKey ?? null,
|
||||||
|
certPublicKey: data.certPublicKey ?? null,
|
||||||
|
} as unknown as HostResolutionCredentialRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SharedCredentialSecretsManager };
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
credentialAccess,
|
||||||
|
hostAccess,
|
||||||
|
hosts,
|
||||||
|
sshCredentials,
|
||||||
|
} from "../database/db/schema.js";
|
||||||
|
import { needsExplicitPersist } from "../database/db/dialect.js";
|
||||||
|
import { createCurrentRepositoryContext } from "../database/repositories/factory.js";
|
||||||
|
import type { HostUpdate } from "../database/repositories/host-repository.js";
|
||||||
|
import type { NewCredentialRecord } from "../database/repositories/credential-repository.js";
|
||||||
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
|
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
|
||||||
|
import { databaseLogger } from "./logger.js";
|
||||||
|
import { SharedHostSecretsManager } from "./shared-host-secrets-manager.js";
|
||||||
|
import { SharedCredentialSecretsManager } from "./shared-credential-secrets-manager.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands one user's hosts and credentials to another before the account is
|
||||||
|
* deleted, so what they shared keeps working for everyone else: rows are
|
||||||
|
* re-encrypted under the successor's key, the grants they issued are now
|
||||||
|
* the successor's, and every recipient's snapshot is rebuilt from the new
|
||||||
|
* owner's copy.
|
||||||
|
*/
|
||||||
|
export async function transferOwnership(
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
): Promise<{ hosts: number; credentials: number }> {
|
||||||
|
if (fromUserId === toUserId) {
|
||||||
|
throw new Error("Cannot transfer ownership to the same user");
|
||||||
|
}
|
||||||
|
const context = createCurrentRepositoryContext();
|
||||||
|
const fromKey = DataCrypto.validateUserAccess(fromUserId);
|
||||||
|
const toKey = DataCrypto.validateUserAccess(toUserId);
|
||||||
|
const credentialRows = await context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, fromUserId));
|
||||||
|
const hostRows = await context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.userId, fromUserId));
|
||||||
|
|
||||||
|
// Decrypt and re-encrypt everything before opening the write transaction.
|
||||||
|
// A corrupt row therefore fails without changing ownership of earlier rows.
|
||||||
|
const credentials = credentialRows.map((row) => {
|
||||||
|
const plain = DataCrypto.decryptRecord(
|
||||||
|
"ssh_credentials",
|
||||||
|
row,
|
||||||
|
fromUserId,
|
||||||
|
fromKey,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
userId: _userId,
|
||||||
|
...fields
|
||||||
|
} = plain as Record<string, unknown>;
|
||||||
|
const encrypted = DataCrypto.encryptRecord(
|
||||||
|
"ssh_credentials",
|
||||||
|
{ ...fields, id: row.id },
|
||||||
|
toUserId,
|
||||||
|
toKey,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
delete encrypted.id;
|
||||||
|
return { id: row.id, update: encrypted as Partial<NewCredentialRecord> };
|
||||||
|
});
|
||||||
|
const transferredHosts = hostRows.map((row) => {
|
||||||
|
const plain = DataCrypto.decryptRecord(
|
||||||
|
"ssh_data",
|
||||||
|
row,
|
||||||
|
fromUserId,
|
||||||
|
fromKey,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
userId: _userId,
|
||||||
|
...fields
|
||||||
|
} = plain as Record<string, unknown>;
|
||||||
|
const encrypted = DataCrypto.encryptRecord(
|
||||||
|
"ssh_data",
|
||||||
|
{ ...fields, id: row.id },
|
||||||
|
toUserId,
|
||||||
|
toKey,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
delete encrypted.id;
|
||||||
|
return { id: row.id, update: encrypted as HostUpdate };
|
||||||
|
});
|
||||||
|
|
||||||
|
const apply = (tx: typeof context.drizzle, sync: boolean) => {
|
||||||
|
const writes = [
|
||||||
|
...credentials.map(({ id, update }) =>
|
||||||
|
tx
|
||||||
|
.update(sshCredentials)
|
||||||
|
.set({ ...update, userId: toUserId })
|
||||||
|
.where(eq(sshCredentials.id, id)),
|
||||||
|
),
|
||||||
|
...transferredHosts.map(({ id, update }) =>
|
||||||
|
tx
|
||||||
|
.update(hosts)
|
||||||
|
.set({ ...update, userId: toUserId })
|
||||||
|
.where(eq(hosts.id, id)),
|
||||||
|
),
|
||||||
|
tx
|
||||||
|
.update(hostAccess)
|
||||||
|
.set({ grantedBy: toUserId })
|
||||||
|
.where(eq(hostAccess.grantedBy, fromUserId)),
|
||||||
|
tx
|
||||||
|
.update(credentialAccess)
|
||||||
|
.set({ grantedBy: toUserId })
|
||||||
|
.where(eq(credentialAccess.grantedBy, fromUserId)),
|
||||||
|
];
|
||||||
|
if (sync) {
|
||||||
|
for (const write of writes) write.run();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Promise.all(writes);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (context.dialect === "sqlite") {
|
||||||
|
context.drizzle.transaction((tx) => apply(tx, true));
|
||||||
|
} else {
|
||||||
|
await context.drizzle.transaction((tx) => apply(tx, false));
|
||||||
|
}
|
||||||
|
if (needsExplicitPersist(context.dialect)) {
|
||||||
|
await DatabaseSaveTrigger.forceSave("transfer_ownership");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialIds = credentials.map(({ id }) => id);
|
||||||
|
const hostIds = transferredHosts.map(({ id }) => id);
|
||||||
|
|
||||||
|
const hostSecrets = SharedHostSecretsManager.getInstance();
|
||||||
|
for (const hostId of hostIds) {
|
||||||
|
try {
|
||||||
|
await hostSecrets.resyncHost(hostId);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Failed to resync host shares after transfer", {
|
||||||
|
operation: "transfer_ownership_host_resync",
|
||||||
|
hostId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const credentialSecrets = SharedCredentialSecretsManager.getInstance();
|
||||||
|
for (const credentialId of credentialIds) {
|
||||||
|
try {
|
||||||
|
await credentialSecrets.resyncCredential(credentialId, toUserId);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Failed to resync credential shares after transfer", {
|
||||||
|
operation: "transfer_ownership_credential_resync",
|
||||||
|
credentialId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.info("Ownership transferred", {
|
||||||
|
operation: "transfer_ownership",
|
||||||
|
fromUserId,
|
||||||
|
toUserId,
|
||||||
|
hosts: hostIds.length,
|
||||||
|
credentials: credentialIds.length,
|
||||||
|
});
|
||||||
|
return { hosts: hostIds.length, credentials: credentialIds.length };
|
||||||
|
}
|
||||||
@@ -206,6 +206,10 @@ export type Credential = {
|
|||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
sortOrder?: number | null;
|
sortOrder?: number | null;
|
||||||
certPublicKey?: string;
|
certPublicKey?: string;
|
||||||
|
/** Set when someone else owns this credential and shared it with you. */
|
||||||
|
isShared?: boolean;
|
||||||
|
ownerUsername?: string | null;
|
||||||
|
permissionLevel?: "use" | "manage";
|
||||||
};
|
};
|
||||||
|
|
||||||
// HashiCorp Vault SSH signer profile — shareable connection settings only
|
// HashiCorp Vault SSH signer profile — shareable connection settings only
|
||||||
|
|||||||
@@ -356,6 +356,49 @@ export async function shareSnippetFolder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CredentialPermissionLevel = "use" | "manage";
|
||||||
|
|
||||||
|
export async function shareCredential(
|
||||||
|
credentialId: number,
|
||||||
|
targets: ShareTarget[],
|
||||||
|
permissionLevel: CredentialPermissionLevel,
|
||||||
|
durationHours?: number,
|
||||||
|
): Promise<{ success: boolean; expiresAt: string | null }> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.post(
|
||||||
|
`/rbac/credential/${credentialId}/share`,
|
||||||
|
{ targets, permissionLevel, durationHours },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "share credential");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCredentialAccess(
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<{ access: AccessRecord[] }> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.get(
|
||||||
|
`/rbac/credential/${credentialId}/access`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "get credential access");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeCredentialAccess(
|
||||||
|
credentialId: number,
|
||||||
|
accessId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rbacApi.delete(`/rbac/credential/${credentialId}/access/${accessId}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "revoke credential access");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSnippetAccess(
|
export async function getSnippetAccess(
|
||||||
snippetId: number,
|
snippetId: number,
|
||||||
): Promise<{ accessList: AccessRecord[] }> {
|
): Promise<{ accessList: AccessRecord[] }> {
|
||||||
|
|||||||
@@ -154,10 +154,11 @@ export async function removeAdminStatus(
|
|||||||
|
|
||||||
export async function deleteUser(
|
export async function deleteUser(
|
||||||
username: string,
|
username: string,
|
||||||
|
successorUserId?: string | "none",
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.delete("/users/delete-user", {
|
const response = await authApi.delete("/users/delete-user", {
|
||||||
data: { username },
|
data: { username, successorUserId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -114,6 +114,32 @@
|
|||||||
"cloneCredentialAction": "Clone credential",
|
"cloneCredentialAction": "Clone credential",
|
||||||
"clonedCredential": "Cloned {{name}}",
|
"clonedCredential": "Cloned {{name}}",
|
||||||
"clonedCredentialName": "{{name}} (copy)",
|
"clonedCredentialName": "{{name}} (copy)",
|
||||||
|
"shareCredentialAction": "Share credential",
|
||||||
|
"sharedBadge": "shared",
|
||||||
|
"sharedBy": "Shared by {{owner}}",
|
||||||
|
"share": {
|
||||||
|
"title": "Share \"{{name}}\"",
|
||||||
|
"description": "Recipients get their own encrypted copy of the secrets. It is refreshed whenever the credential changes and removed when the share is revoked.",
|
||||||
|
"users": "Users",
|
||||||
|
"roles": "Roles",
|
||||||
|
"searchPlaceholder": "Search...",
|
||||||
|
"permission": "Permission",
|
||||||
|
"levelUse": "Use",
|
||||||
|
"levelManage": "Manage",
|
||||||
|
"levelUseDesc": "Use: attach the credential to hosts and connect. The secret itself stays hidden.",
|
||||||
|
"levelManageDesc": "Manage: also edit the credential and share it with others.",
|
||||||
|
"expires": "Expires",
|
||||||
|
"expiry": {
|
||||||
|
"never": "Never",
|
||||||
|
"oneDay": "1 day",
|
||||||
|
"sevenDays": "7 days",
|
||||||
|
"thirtyDays": "30 days"
|
||||||
|
},
|
||||||
|
"currentAccess": "Shared with",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"shared": "Credential shared",
|
||||||
|
"shareButton": "Share"
|
||||||
|
},
|
||||||
"deleteCredentialAction": "Delete credential",
|
"deleteCredentialAction": "Delete credential",
|
||||||
"editCredentialAction": "Edit credential",
|
"editCredentialAction": "Edit credential",
|
||||||
"failedToCloneCredential": "Failed to clone credential",
|
"failedToCloneCredential": "Failed to clone credential",
|
||||||
@@ -3653,6 +3679,10 @@
|
|||||||
"snippetDeletedSuccess": "Snippet deleted",
|
"snippetDeletedSuccess": "Snippet deleted",
|
||||||
"snippetDeleteFailed": "Failed to delete snippet",
|
"snippetDeleteFailed": "Failed to delete snippet",
|
||||||
"noSessionsForUser": "No active sessions",
|
"noSessionsForUser": "No active sessions",
|
||||||
|
"deleteSuccessorLabel": "Hand over hosts and credentials to",
|
||||||
|
"deleteSuccessorMe": "Me (the deleting admin)",
|
||||||
|
"deleteSuccessorNone": "Nobody — delete them",
|
||||||
|
"deleteSuccessorDesc": "Everything this user shared keeps working under the new owner; choosing nobody removes their hosts, credentials and shares.",
|
||||||
"deleteUserDangerDesc": "Permanently delete {{username}} and all of their data (hosts, credentials, snippets, history). This cannot be undone.",
|
"deleteUserDangerDesc": "Permanently delete {{username}} and all of their data (hosts, credentials, snippets, history). This cannot be undone.",
|
||||||
"deleteUserConfirm": "Permanently delete {{username}} and all of their data?",
|
"deleteUserConfirm": "Permanently delete {{username}} and all of their data?",
|
||||||
"deleteUserAdminBlocked": "Remove admin status before deleting this user.",
|
"deleteUserAdminBlocked": "Remove admin status before deleting this user.",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { getUserList } from "@/main-axios";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -118,6 +119,23 @@ export function AdminUserManagePanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<ManageTabId>("account");
|
const [activeTab, setActiveTab] = useState<ManageTabId>("account");
|
||||||
|
// Who inherits the hosts and credentials when this account goes:
|
||||||
|
// the deleting admin by default, another user, or nobody.
|
||||||
|
const [successor, setSuccessor] = useState<"me" | "none" | string>("me");
|
||||||
|
const [successorOptions, setSuccessorOptions] = useState<
|
||||||
|
Array<{ id: string; username: string }>
|
||||||
|
>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getUserList()
|
||||||
|
.then((r) =>
|
||||||
|
setSuccessorOptions(
|
||||||
|
r.users
|
||||||
|
.filter((u) => u.userId !== user.id)
|
||||||
|
.map((u) => ({ id: u.userId, username: u.username })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [user.id]);
|
||||||
const [editor, setEditor] = useState<EditorState>(null);
|
const [editor, setEditor] = useState<EditorState>(null);
|
||||||
const [editorTab, setEditorTab] = useState("general");
|
const [editorTab, setEditorTab] = useState("general");
|
||||||
const [editorProtocols, setEditorProtocols] = useState({
|
const [editorProtocols, setEditorProtocols] = useState({
|
||||||
@@ -332,7 +350,8 @@ export function AdminUserManagePanel({
|
|||||||
async function handleDeleteUser() {
|
async function handleDeleteUser() {
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
try {
|
try {
|
||||||
await deleteUser(user.username);
|
if (successor === "me") await deleteUser(user.username);
|
||||||
|
else await deleteUser(user.username, successor);
|
||||||
toast.success(t("admin.deleteUserSuccess", { username: user.username }));
|
toast.success(t("admin.deleteUserSuccess", { username: user.username }));
|
||||||
onUserDeleted();
|
onUserDeleted();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1209,6 +1228,25 @@ export function AdminUserManagePanel({
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||||
|
{t("admin.deleteSuccessorLabel")}
|
||||||
|
<select
|
||||||
|
value={successor}
|
||||||
|
onChange={(e) => setSuccessor(e.target.value)}
|
||||||
|
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
|
||||||
|
>
|
||||||
|
<option value="me">{t("admin.deleteSuccessorMe")}</option>
|
||||||
|
{successorOptions.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="none">
|
||||||
|
{t("admin.deleteSuccessorNone")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span>{t("admin.deleteSuccessorDesc")}</span>
|
||||||
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, Trash2, Users, UserRound } from "lucide-react";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Input } from "@/components/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/dialog";
|
||||||
|
import {
|
||||||
|
getCredentialAccess,
|
||||||
|
getRoles,
|
||||||
|
revokeCredentialAccess,
|
||||||
|
shareCredential,
|
||||||
|
type CredentialPermissionLevel,
|
||||||
|
type ShareTarget,
|
||||||
|
} from "@/api/rbac-api";
|
||||||
|
import { getUserList, type AccessRecord, type Role } from "@/main-axios";
|
||||||
|
import type { Credential } from "@/types/ui-types";
|
||||||
|
import { getErrorMessage } from "@/lib/error-message";
|
||||||
|
|
||||||
|
const EXPIRY_PRESETS = [
|
||||||
|
{ key: "never", hours: undefined },
|
||||||
|
{ key: "oneDay", hours: 24 },
|
||||||
|
{ key: "sevenDays", hours: 24 * 7 },
|
||||||
|
{ key: "thirtyDays", hours: 24 * 30 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function CredentialShareModal({
|
||||||
|
credential,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
credential: Credential | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [tab, setTab] = useState<"user" | "role">("user");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [users, setUsers] = useState<Array<{ id: string; username: string }>>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<Set<number>>(new Set());
|
||||||
|
const [level, setLevel] = useState<CredentialPermissionLevel>("use");
|
||||||
|
const [expiry, setExpiry] =
|
||||||
|
useState<(typeof EXPIRY_PRESETS)[number]["key"]>("never");
|
||||||
|
const [access, setAccess] = useState<AccessRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const credentialId = credential ? Number(credential.id) : null;
|
||||||
|
|
||||||
|
const loadAccess = useCallback(async () => {
|
||||||
|
if (!credentialId) return;
|
||||||
|
try {
|
||||||
|
setAccess((await getCredentialAccess(credentialId)).access);
|
||||||
|
} catch {
|
||||||
|
setAccess([]);
|
||||||
|
}
|
||||||
|
}, [credentialId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!credential) return;
|
||||||
|
setSelectedUsers(new Set());
|
||||||
|
setSelectedRoles(new Set());
|
||||||
|
setSearch("");
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
getUserList().catch(() => ({ users: [] })),
|
||||||
|
getRoles().catch(() => ({ roles: [] as Role[] })),
|
||||||
|
loadAccess(),
|
||||||
|
])
|
||||||
|
.then(([userResult, roleResult]) => {
|
||||||
|
setUsers(
|
||||||
|
userResult.users.map((u) => ({ id: u.userId, username: u.username })),
|
||||||
|
);
|
||||||
|
setRoles(roleResult.roles);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [credential, loadAccess]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(
|
||||||
|
() =>
|
||||||
|
users.filter((u) =>
|
||||||
|
u.username.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
),
|
||||||
|
[users, search],
|
||||||
|
);
|
||||||
|
const filteredRoles = useMemo(
|
||||||
|
() =>
|
||||||
|
roles.filter((r) =>
|
||||||
|
(r.displayName || r.name).toLowerCase().includes(search.toLowerCase()),
|
||||||
|
),
|
||||||
|
[roles, search],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleShare() {
|
||||||
|
if (!credentialId) return;
|
||||||
|
const targets: ShareTarget[] = [
|
||||||
|
...Array.from(selectedUsers, (id) => ({ type: "user" as const, id })),
|
||||||
|
...Array.from(selectedRoles, (id) => ({ type: "role" as const, id })),
|
||||||
|
];
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const preset = EXPIRY_PRESETS.find((p) => p.key === expiry);
|
||||||
|
await shareCredential(credentialId, targets, level, preset?.hours);
|
||||||
|
toast.success(t("credentials.share.shared"));
|
||||||
|
setSelectedUsers(new Set());
|
||||||
|
setSelectedRoles(new Set());
|
||||||
|
await loadAccess();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(getErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(entry: AccessRecord) {
|
||||||
|
if (!credentialId) return;
|
||||||
|
try {
|
||||||
|
await revokeCredentialAccess(credentialId, entry.id);
|
||||||
|
await loadAccess();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(getErrorMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
|
||||||
|
const next = new Set(set);
|
||||||
|
if (next.has(value)) next.delete(value);
|
||||||
|
else next.add(value);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!credential} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("credentials.share.title", { name: credential?.name ?? "" })}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("credentials.share.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["user", "role"] as const).map((kind) => (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(kind)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1 py-1 text-[10px] font-semibold border ${
|
||||||
|
tab === kind
|
||||||
|
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{kind === "user" ? (
|
||||||
|
<UserRound className="size-3" />
|
||||||
|
) : (
|
||||||
|
<Users className="size-3" />
|
||||||
|
)}
|
||||||
|
{t(
|
||||||
|
kind === "user"
|
||||||
|
? "credentials.share.users"
|
||||||
|
: "credentials.share.roles",
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder={t("credentials.share.searchPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||||
|
{tab === "user"
|
||||||
|
? filteredUsers.map((u) => (
|
||||||
|
<label
|
||||||
|
key={u.id}
|
||||||
|
className="flex items-center gap-2 text-xs px-1 py-0.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedUsers.has(u.id)}
|
||||||
|
onChange={() =>
|
||||||
|
setSelectedUsers((s) => toggle(s, u.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{u.username}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
: filteredRoles.map((r) => (
|
||||||
|
<label
|
||||||
|
key={r.id}
|
||||||
|
className="flex items-center gap-2 text-xs px-1 py-0.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRoles.has(r.id)}
|
||||||
|
onChange={() =>
|
||||||
|
setSelectedRoles((s) => toggle(s, r.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{r.displayName || r.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("credentials.share.permission")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={level}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLevel(e.target.value as CredentialPermissionLevel)
|
||||||
|
}
|
||||||
|
className="h-8 border border-border bg-background px-2 text-xs outline-none"
|
||||||
|
>
|
||||||
|
<option value="use">{t("credentials.share.levelUse")}</option>
|
||||||
|
<option value="manage">
|
||||||
|
{t("credentials.share.levelManage")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("credentials.share.expires")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={expiry}
|
||||||
|
onChange={(e) => setExpiry(e.target.value as typeof expiry)}
|
||||||
|
className="h-8 border border-border bg-background px-2 text-xs outline-none"
|
||||||
|
>
|
||||||
|
{EXPIRY_PRESETS.map((p) => (
|
||||||
|
<option key={p.key} value={p.key}>
|
||||||
|
{t(`credentials.share.expiry.${p.key}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
level === "manage"
|
||||||
|
? "credentials.share.levelManageDesc"
|
||||||
|
: "credentials.share.levelUseDesc",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{access.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 border-t border-border pt-2">
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("credentials.share.currentAccess")}
|
||||||
|
</span>
|
||||||
|
{access.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center gap-2 text-xs px-1 py-0.5"
|
||||||
|
>
|
||||||
|
{entry.targetType === "role" ? (
|
||||||
|
<Users className="size-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<UserRound className="size-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{entry.targetType === "role"
|
||||||
|
? entry.roleDisplayName || entry.roleName
|
||||||
|
: entry.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase text-muted-foreground">
|
||||||
|
{entry.permissionLevel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => void handleRevoke(entry)}
|
||||||
|
title={t("credentials.share.revoke")}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("common.close")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleShare()}
|
||||||
|
disabled={
|
||||||
|
saving || (selectedUsers.size === 0 && selectedRoles.size === 0)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="size-3.5 mr-1 animate-spin" />}
|
||||||
|
{t("credentials.share.shareButton")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import React, {
|
|||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CredentialShareModal } from "./CredentialShareModal";
|
||||||
|
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
|
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
|
||||||
@@ -73,6 +74,9 @@ export function HostManager({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
|
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
|
||||||
|
const [shareCredential, setShareCredential] = useState<Credential | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [editingCredential, setEditingCredential] = useState<
|
const [editingCredential, setEditingCredential] = useState<
|
||||||
Credential | "new" | null
|
Credential | "new" | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -736,11 +740,17 @@ export function HostManager({
|
|||||||
onEditCredential={handleEditCredential}
|
onEditCredential={handleEditCredential}
|
||||||
onCloneCredential={handleCloneCredential}
|
onCloneCredential={handleCloneCredential}
|
||||||
onDeleteCredential={handleConfirmDeleteCredential}
|
onDeleteCredential={handleConfirmDeleteCredential}
|
||||||
|
onShareCredential={setShareCredential}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CredentialShareModal
|
||||||
|
credential={shareCredential}
|
||||||
|
onClose={() => setShareCredential(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Confirm dialog */}
|
{/* Confirm dialog */}
|
||||||
{confirmDialog && (
|
{confirmDialog && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ type RawSSHHost = SSHHostWithStatus & {
|
|||||||
type HostQuickAction = Host["quickActions"][number];
|
type HostQuickAction = Host["quickActions"][number];
|
||||||
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
|
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
|
||||||
type RawCredential = {
|
type RawCredential = {
|
||||||
|
isShared?: boolean;
|
||||||
|
ownerUsername?: string | null;
|
||||||
|
permissionLevel?: "use" | "manage";
|
||||||
id: number | string;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -192,5 +195,8 @@ export function mapCredentials(res: unknown): Credential[] {
|
|||||||
pin: c.pin ?? false,
|
pin: c.pin ?? false,
|
||||||
sortOrder: c.sortOrder ?? null,
|
sortOrder: c.sortOrder ?? null,
|
||||||
certPublicKey: c.certPublicKey ?? undefined,
|
certPublicKey: c.certPublicKey ?? undefined,
|
||||||
|
isShared: c.isShared ?? false,
|
||||||
|
ownerUsername: c.ownerUsername ?? null,
|
||||||
|
permissionLevel: c.permissionLevel,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Pin,
|
Pin,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Share2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -71,6 +72,7 @@ export function CredentialItem({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onClone,
|
onClone,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onShare,
|
||||||
}: {
|
}: {
|
||||||
cred: Credential;
|
cred: Credential;
|
||||||
usedByCount?: number;
|
usedByCount?: number;
|
||||||
@@ -104,6 +106,7 @@ export function CredentialItem({
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onClone: () => void;
|
onClone: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onShare?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
|
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
|
||||||
@@ -158,8 +161,14 @@ export function CredentialItem({
|
|||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// A recipient sees only what their grant allows: "manage" may edit and
|
||||||
|
// re-share, "use" may only use. Cloning and deleting stay with the owner.
|
||||||
|
const canEdit = !cred.isShared || cred.permissionLevel === "manage";
|
||||||
|
const canShare =
|
||||||
|
!!onShare && (!cred.isShared || cred.permissionLevel === "manage");
|
||||||
const managementButtons = (
|
const managementButtons = (
|
||||||
<>
|
<>
|
||||||
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
title={t("credentials.editCredentialAction")}
|
title={t("credentials.editCredentialAction")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -170,6 +179,21 @@ export function CredentialItem({
|
|||||||
>
|
>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<button
|
||||||
|
title={t("credentials.shareCredentialAction")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShare?.();
|
||||||
|
}}
|
||||||
|
className={trayButtonClass}
|
||||||
|
>
|
||||||
|
<Share2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!cred.isShared && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
title={t("credentials.cloneCredentialAction")}
|
title={t("credentials.cloneCredentialAction")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -191,6 +215,8 @@ export function CredentialItem({
|
|||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const trayOpenState = isTrayOpen || isMenuOpen;
|
const trayOpenState = isTrayOpen || isMenuOpen;
|
||||||
@@ -265,6 +291,17 @@ export function CredentialItem({
|
|||||||
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
|
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
|
||||||
>
|
>
|
||||||
{cred.name}
|
{cred.name}
|
||||||
|
{cred.isShared && (
|
||||||
|
<span
|
||||||
|
className="ml-1 inline-flex items-center gap-0.5 text-[9px] uppercase text-accent-brand/80"
|
||||||
|
title={t("credentials.sharedBy", {
|
||||||
|
owner: cred.ownerUsername ?? "",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Share2 className="size-2.5" />
|
||||||
|
{t("credentials.sharedBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
|
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function CredentialSidebarTree({
|
|||||||
onEditCredential,
|
onEditCredential,
|
||||||
onCloneCredential,
|
onCloneCredential,
|
||||||
onDeleteCredential,
|
onDeleteCredential,
|
||||||
|
onShareCredential,
|
||||||
usedByCounts,
|
usedByCounts,
|
||||||
termixIdLinkedIds,
|
termixIdLinkedIds,
|
||||||
query = "",
|
query = "",
|
||||||
@@ -54,6 +55,7 @@ export function CredentialSidebarTree({
|
|||||||
onEditCredential: (cred: Credential) => void;
|
onEditCredential: (cred: Credential) => void;
|
||||||
onCloneCredential: (cred: Credential) => void;
|
onCloneCredential: (cred: Credential) => void;
|
||||||
onDeleteCredential: (cred: Credential) => void;
|
onDeleteCredential: (cred: Credential) => void;
|
||||||
|
onShareCredential?: (cred: Credential) => void;
|
||||||
usedByCounts?: Map<string, number>;
|
usedByCounts?: Map<string, number>;
|
||||||
termixIdLinkedIds?: Set<number>;
|
termixIdLinkedIds?: Set<number>;
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -438,6 +440,7 @@ export function CredentialSidebarTree({
|
|||||||
onEdit={() => onEditCredential(item)}
|
onEdit={() => onEditCredential(item)}
|
||||||
onClone={() => onCloneCredential(item)}
|
onClone={() => onCloneCredential(item)}
|
||||||
onDelete={() => onDeleteCredential(item)}
|
onDelete={() => onDeleteCredential(item)}
|
||||||
|
onShare={() => onShareCredential?.(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const api = vi.hoisted(() => ({
|
|||||||
createApiKey: vi.fn(async () => ({})),
|
createApiKey: vi.fn(async () => ({})),
|
||||||
deleteApiKey: vi.fn(async () => ({})),
|
deleteApiKey: vi.fn(async () => ({})),
|
||||||
deleteUser: vi.fn(async () => ({})),
|
deleteUser: vi.fn(async () => ({})),
|
||||||
|
getUserList: vi.fn(async () => ({ users: [] })),
|
||||||
getUserRoles: vi.fn(async () => ({ roles: [] as unknown[] })),
|
getUserRoles: vi.fn(async () => ({ roles: [] as unknown[] })),
|
||||||
assignRoleToUser: vi.fn(async () => ({})),
|
assignRoleToUser: vi.fn(async () => ({})),
|
||||||
removeRoleFromUser: vi.fn(async () => ({})),
|
removeRoleFromUser: vi.fn(async () => ({})),
|
||||||
|
|||||||
Reference in New Issue
Block a user