feat: add shared PostgreSQL client deletion

This commit is contained in:
2026-08-11 18:04:45 +08:00
parent 9efb0bbe63
commit 1665ed16b1
5 changed files with 81 additions and 2 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
appname=nxsir.postgresql appname=nxsir.postgresql
version=15.1.1 version=15.1.2
display_name=PostgreSQL 共享服务 display_name=PostgreSQL 共享服务
desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复 desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复
platform=x86 platform=x86
+16 -1
View File
@@ -161,6 +161,21 @@ async function revokeClient(client: Client) {
await loadAll(); await loadAll();
} }
async function deleteClient(client: Client) {
await ElMessageBox.confirm(
`删除后 ${client.displayName} 的数据库 ${client.databaseName} 和角色 ${client.roleName} 将被彻底销毁,不可恢复。`,
"删除接入应用",
{ type: "warning", confirmButtonText: "确认删除", confirmButtonClass: "el-button--danger" }
);
const { value } = await ElMessageBox.prompt(
`请输入 ${client.appId} 确认删除。`,
"二次确认",
{ type: "warning" }
);
await api(`/api/v1/clients/${encodeURIComponent(client.appId)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
await loadAll();
}
async function createDatabase() { async function createDatabase() {
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建数据库", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" }); const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建数据库", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
await api("/api/v1/databases", { method: "POST", body: JSON.stringify({ name }) }); await api("/api/v1/databases", { method: "POST", body: JSON.stringify({ name }) });
@@ -285,7 +300,7 @@ onMounted(async () => {
<el-table-column prop="roleName" label="角色" min-width="190" /> <el-table-column prop="roleName" label="角色" min-width="190" />
<el-table-column label="扩展"><template #default="{ row }">{{ row.extensions.join(", ") || "-" }}</template></el-table-column> <el-table-column label="扩展"><template #default="{ row }">{{ row.extensions.join(", ") || "-" }}</template></el-table-column>
<el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status === "active" ? "正常" : "已吊销" }}</el-tag></template></el-table-column> <el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status === "active" ? "正常" : "已吊销" }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="rotateClient(row)">轮换密码</el-button><el-button size="small" type="danger" plain @click="revokeClient(row)">吊销</el-button></template></el-table-column> <el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="rotateClient(row)">轮换密码</el-button><el-button size="small" type="danger" plain @click="revokeClient(row)">吊销</el-button><el-button size="small" type="danger" @click="deleteClient(row)">删除</el-button></template></el-table-column>
</el-table> </el-table>
</section> </section>
+16
View File
@@ -87,6 +87,7 @@ enroll() {
enroll liverecorder 'Live Recorder' '[]' "$WORK_DIR/live.json" enroll liverecorder 'Live Recorder' '[]' "$WORK_DIR/live.json"
enroll imagefind-test 'ImageFind Test Client' '["vector"]' "$WORK_DIR/image.json" enroll imagefind-test 'ImageFind Test Client' '["vector"]' "$WORK_DIR/image.json"
enroll delete-test 'Disposable Test Client' '[]' "$WORK_DIR/delete.json"
json_field() { json_field() {
field=$1 field=$1
@@ -100,8 +101,11 @@ LIVE_PASSWORD=$(json_field password "$WORK_DIR/live.json")
IMAGE_DB=$(json_field database "$WORK_DIR/image.json") IMAGE_DB=$(json_field database "$WORK_DIR/image.json")
IMAGE_USER=$(json_field username "$WORK_DIR/image.json") IMAGE_USER=$(json_field username "$WORK_DIR/image.json")
IMAGE_PASSWORD=$(json_field password "$WORK_DIR/image.json") IMAGE_PASSWORD=$(json_field password "$WORK_DIR/image.json")
DELETE_DB=$(json_field database "$WORK_DIR/delete.json")
DELETE_USER=$(json_field username "$WORK_DIR/delete.json")
test -n "$LIVE_DB" && test -n "$LIVE_USER" && test -n "$LIVE_PASSWORD" test -n "$LIVE_DB" && test -n "$LIVE_USER" && test -n "$LIVE_PASSWORD"
test -n "$IMAGE_DB" && test -n "$IMAGE_USER" && test -n "$IMAGE_PASSWORD" test -n "$IMAGE_DB" && test -n "$IMAGE_USER" && test -n "$IMAGE_PASSWORD"
test -n "$DELETE_DB" && test -n "$DELETE_USER"
test "$LIVE_DB" != "$IMAGE_DB" test "$LIVE_DB" != "$IMAGE_DB"
test "$LIVE_USER" != "$IMAGE_USER" test "$LIVE_USER" != "$IMAGE_USER"
@@ -124,6 +128,18 @@ if run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -
exit 1 exit 1
fi fi
if curl -fsS -b "$COOKIE_JAR" -X DELETE \
"$BASE_URL/api/v1/clients/delete-test?confirmation=wrong-name" >/dev/null 2>&1; then
printf '%s\n' 'client deletion accepted a mismatched confirmation' >&2
exit 1
fi
curl -fsS -b "$COOKIE_JAR" -X DELETE \
"$BASE_URL/api/v1/clients/delete-test?confirmation=delete-test" >/dev/null
test -z "$(run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d postgres \
-Atqc "SELECT 1 FROM pg_database WHERE datname='$DELETE_DB'")"
test -z "$(run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d postgres \
-Atqc "SELECT 1 FROM pg_roles WHERE rolname='$DELETE_USER'")"
curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \ curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \
--data "{\"database\":\"$IMAGE_DB\",\"sql\":\"SELECT count(*) FROM smoke_vectors\"}" \ --data "{\"database\":\"$IMAGE_DB\",\"sql\":\"SELECT count(*) FROM smoke_vectors\"}" \
"$BASE_URL/api/v1/query" | grep -q '"rowCount":1' "$BASE_URL/api/v1/query" | grep -q '"rowCount":1'
@@ -265,6 +265,48 @@ public sealed class PostgresAdminService
await AuditAsync("client.revoke", appId, new { client.RoleName }, cancellationToken); await AuditAsync("client.revoke", appId, new { client.RoleName }, cancellationToken);
} }
public async Task DeleteClientAsync(string appId, string confirmation, CancellationToken cancellationToken)
{
if (confirmation != appId)
{
throw new ArgumentException("确认名称不匹配。");
}
var client = await FindClientAsync(appId, cancellationToken)
?? throw new KeyNotFoundException("客户端不存在。");
await _provisionLock.WaitAsync(cancellationToken);
try
{
await using (var connection = await OpenAsync("postgres", cancellationToken))
{
await ExecuteNonQueryAsync(
connection,
$"DROP DATABASE IF EXISTS {QuoteIdentifier(client.DatabaseName)} WITH (FORCE)",
cancellationToken);
await ExecuteNonQueryAsync(
connection,
$"DROP ROLE IF EXISTS {QuoteIdentifier(client.RoleName)}",
cancellationToken);
}
await using (var metadata = await OpenAsync(MetadataDatabase, cancellationToken))
await using (var command = new NpgsqlCommand(
"DELETE FROM managed_clients WHERE app_id = @appId",
metadata))
{
command.Parameters.AddWithValue("appId", appId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await AuditAsync("client.delete", appId, new { client.DatabaseName, client.RoleName }, cancellationToken);
}
finally
{
_provisionLock.Release();
}
}
public async Task<IReadOnlyList<DatabaseSummary>> ListDatabasesAsync(CancellationToken cancellationToken) public async Task<IReadOnlyList<DatabaseSummary>> ListDatabasesAsync(CancellationToken cancellationToken)
{ {
var clients = await ListClientsAsync(cancellationToken); var clients = await ListClientsAsync(cancellationToken);
+6
View File
@@ -146,6 +146,12 @@ app.MapPost("/api/v1/clients/{appId}/revoke", async (string appId, CancellationT
await postgres.RevokeClientAsync(appId, cancellationToken); await postgres.RevokeClientAsync(appId, cancellationToken);
return Results.NoContent(); return Results.NoContent();
}); });
app.MapDelete("/api/v1/clients/{appId}", async (
string appId, string confirmation, CancellationToken cancellationToken) =>
{
await postgres.DeleteClientAsync(appId, confirmation, cancellationToken);
return Results.NoContent();
});
app.MapPost("/api/v1/enrollment-token/rotate", () => app.MapPost("/api/v1/enrollment-token/rotate", () =>
Results.Ok(new { token = secretStore.RotateEnrollmentToken() })); Results.Ok(new { token = secretStore.RotateEnrollmentToken() }));