feat: add shared PostgreSQL client deletion
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user