feat: add shared PostgreSQL client deletion
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
appname=nxsir.postgresql
|
||||
version=15.1.1
|
||||
version=15.1.2
|
||||
display_name=PostgreSQL 共享服务
|
||||
desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复
|
||||
platform=x86
|
||||
|
||||
@@ -161,6 +161,21 @@ async function revokeClient(client: Client) {
|
||||
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() {
|
||||
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 }) });
|
||||
@@ -285,7 +300,7 @@ onMounted(async () => {
|
||||
<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 }"><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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ enroll() {
|
||||
|
||||
enroll liverecorder 'Live Recorder' '[]' "$WORK_DIR/live.json"
|
||||
enroll imagefind-test 'ImageFind Test Client' '["vector"]' "$WORK_DIR/image.json"
|
||||
enroll delete-test 'Disposable Test Client' '[]' "$WORK_DIR/delete.json"
|
||||
|
||||
json_field() {
|
||||
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_USER=$(json_field username "$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 "$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_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
|
||||
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' \
|
||||
--data "{\"database\":\"$IMAGE_DB\",\"sql\":\"SELECT count(*) FROM smoke_vectors\"}" \
|
||||
"$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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var clients = await ListClientsAsync(cancellationToken);
|
||||
|
||||
@@ -146,6 +146,12 @@ app.MapPost("/api/v1/clients/{appId}/revoke", async (string appId, CancellationT
|
||||
await postgres.RevokeClientAsync(appId, cancellationToken);
|
||||
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", () =>
|
||||
Results.Ok(new { token = secretStore.RotateEnrollmentToken() }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user