feat(admin): add audited Refine staging console
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { authorizeAdminAccess } from "../src/lib/admin/auth-policy.ts";
|
||||
import type { IdentityUser } from "../src/modules/identity/contracts.ts";
|
||||
|
||||
function user(role: string[]): IdentityUser {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
email: "admin@example.com",
|
||||
emailVerified: true,
|
||||
name: "Admin",
|
||||
image: null,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
test("anonymous admin access is 401", () => {
|
||||
assert.deepEqual(authorizeAdminAccess(null, "read"), {
|
||||
allowed: false,
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
|
||||
test("viewer may read but may not write", () => {
|
||||
assert.deepEqual(authorizeAdminAccess(user(["user", "viewer"]), "read"), {
|
||||
allowed: true,
|
||||
role: "viewer",
|
||||
});
|
||||
assert.deepEqual(authorizeAdminAccess(user(["viewer"]), "write"), {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
test("admin may read and write while unprivileged users are 403", () => {
|
||||
assert.deepEqual(authorizeAdminAccess(user(["admin"]), "read"), {
|
||||
allowed: true,
|
||||
role: "admin",
|
||||
});
|
||||
assert.deepEqual(authorizeAdminAccess(user(["admin"]), "write"), {
|
||||
allowed: true,
|
||||
role: "admin",
|
||||
});
|
||||
assert.deepEqual(authorizeAdminAccess(user(["user"]), "read"), {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const migration = readFileSync(
|
||||
new URL("../supabase/migrations/20260727010000_refine_admin_redemption_audit.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const auth = readFileSync(new URL("../src/lib/admin/auth.ts", import.meta.url), "utf8");
|
||||
const authPolicy = readFileSync(new URL("../src/lib/admin/auth-policy.ts", import.meta.url), "utf8");
|
||||
const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8");
|
||||
const codeRoute = readFileSync(new URL("../src/app/api/admin/codes/[id]/route.ts", import.meta.url), "utf8");
|
||||
const providers = readFileSync(new URL("../src/lib/admin/providers.ts", import.meta.url), "utf8");
|
||||
const readonlyRoutes = ["users", "credit-transactions", "consultations", "audit-logs"].map((resource) =>
|
||||
readFileSync(new URL(`../src/app/api/admin/${resource}/route.ts`, import.meta.url), "utf8"),
|
||||
);
|
||||
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
|
||||
test("admin APIs use persisted Better Auth roles with admin and viewer boundaries", () => {
|
||||
assert.match(auth, /requireIdentityUser/);
|
||||
assert.match(authPolicy, /user\.role\.includes\("admin"\)/);
|
||||
assert.match(authPolicy, /user\.role\.includes\("viewer"\)/);
|
||||
assert.match(authPolicy, /access === "write" && role !== "admin"/);
|
||||
assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/);
|
||||
assert.match(auth, /APP_ENV\?\.trim\(\) === "production"/);
|
||||
assert.match(codesRoute, /requireAdminSession\("write"\)/);
|
||||
assert.match(codeRoute, /requireAdminSession\("write"\)/g);
|
||||
});
|
||||
|
||||
test("readonly resources cannot be mutated through Refine access control", () => {
|
||||
for (const resource of ["users", "credit-transactions", "consultations", "audit-logs"]) {
|
||||
assert.match(providers, new RegExp(`"${resource}"`));
|
||||
}
|
||||
assert.match(providers, /readOnlyResources\.has/);
|
||||
assert.match(providers, /此资源只读/);
|
||||
for (const route of readonlyRoutes) {
|
||||
assert.match(route, /export const POST = readonlyAdminMutation/);
|
||||
assert.match(route, /export const PATCH = readonlyAdminMutation/);
|
||||
assert.match(route, /export const DELETE = readonlyAdminMutation/);
|
||||
}
|
||||
});
|
||||
|
||||
test("redemption code writes are atomic with append-only redacted audit", () => {
|
||||
assert.match(migration, /create table if not exists audit\.admin_audit_logs/);
|
||||
assert.match(migration, /admin_audit_logs_append_only/);
|
||||
assert.match(migration, /redemption_code\.create/);
|
||||
assert.match(migration, /redemption_code\.update/);
|
||||
assert.match(migration, /redemption_code\.revoke/);
|
||||
assert.match(migration, /before_value is null or not \(before_value \?\| array\['code', 'code_hash', 'token', 'secret', 'key'\]\)/);
|
||||
assert.match(migration, /insert into audit\.admin_audit_logs/);
|
||||
assert.match(migration, /redeemed codes are immutable/);
|
||||
assert.match(migration, /revoked codes are immutable/);
|
||||
assert.match(migration, /v_code\.revoked_at is not null/);
|
||||
assert.match(migration, /'revoked_code'/);
|
||||
assert.match(migration, /set local role service_role|profiles_admin_read/);
|
||||
assert.match(migration, /p_codes is null or jsonb_typeof\(p_codes\) is distinct from 'array'/);
|
||||
assert.match(migration, /admin_verified_actor_email/);
|
||||
});
|
||||
|
||||
test("plaintext code is returned only by create and never enters audit snapshots", () => {
|
||||
assert.match(codesRoute, /plainCodes\.map/);
|
||||
assert.match(codesRoute, /code,/);
|
||||
assert.doesNotMatch(codeRoute, /codeHash|code_hash|plainCodes/);
|
||||
const snapshot = migration.match(/create or replace function public\.admin_redemption_code_snapshot[\s\S]*?revoke all on function/);
|
||||
assert.ok(snapshot);
|
||||
assert.doesNotMatch(snapshot[0], /code_hash|'code'/);
|
||||
assert.match(snapshot[0], /'mask'/);
|
||||
});
|
||||
|
||||
test("Refine dependencies and same-origin admin data provider are present", () => {
|
||||
for (const dependency of ["@refinedev/core", "@refinedev/antd", "@refinedev/nextjs-router", "antd"]) {
|
||||
assert.ok(packageJson.dependencies[dependency], `${dependency} missing`);
|
||||
}
|
||||
assert.match(providers, /const apiBase = "\/api\/admin"/);
|
||||
assert.doesNotMatch(providers, /https?:\/\//);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
const actorId = "11111111-1111-4111-8111-111111111111";
|
||||
const codeId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function rpcArgs(requestId: string) {
|
||||
return {
|
||||
p_actor_user_id: actorId,
|
||||
p_actor_email: "admin@example.com",
|
||||
p_actor_role: "admin",
|
||||
p_request_id: requestId,
|
||||
};
|
||||
}
|
||||
|
||||
test("admin code functions reject immutable codes, revoked redemption, and roll back on audit failure", async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
|
||||
},
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /20260727010000_refine_admin_redemption_audit\.sql/);
|
||||
|
||||
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
|
||||
insert into identity.users (id, name, email, email_verified, email_verified_at, role)
|
||||
values ('${actorId}', 'Admin', 'admin@example.com', true, now(), 'admin')
|
||||
`);
|
||||
const userId = fixture.psql(`select id from identity.users where email = 'admin@example.com'`);
|
||||
const admin = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
|
||||
const created = await admin.rpc("admin_create_redemption_codes", {
|
||||
...rpcArgs("create-1"),
|
||||
p_codes: [{
|
||||
codeHash: "a".repeat(64),
|
||||
codeMask: "JYOTISH-****-AUD1",
|
||||
credits: 5,
|
||||
expiresAt: null,
|
||||
note: "initial",
|
||||
}],
|
||||
});
|
||||
assert.equal(created.error, null);
|
||||
assert.equal((created.data as Array<{ code_mask: string }>)[0]?.code_mask, "JYOTISH-****-AUD1");
|
||||
assert.equal(fixture.psql("select count(*) from audit.admin_audit_logs"), "1");
|
||||
assert.doesNotMatch(fixture.psql("select after_value::text from audit.admin_audit_logs"), /[a-f0-9]{64}/);
|
||||
|
||||
const createdId = fixture.psql("select id from public.redemption_codes where code_mask = 'JYOTISH-****-AUD1'");
|
||||
const revoked = await admin.rpc("admin_revoke_redemption_code", {
|
||||
...rpcArgs("revoke-1"),
|
||||
p_code_id: createdId,
|
||||
});
|
||||
assert.equal(revoked.error, null);
|
||||
|
||||
const app = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
|
||||
{ id: userId, email: "admin@example.com" },
|
||||
);
|
||||
const redeemRevoked = await app.rpc("redeem_code", { p_code_hash: "a".repeat(64) });
|
||||
assert.deepEqual(redeemRevoked.data, [{ success: false, credits: null, error_code: "revoked_code" }]);
|
||||
|
||||
fixture.psql(`
|
||||
insert into public.redemption_codes (id, code_hash, code_mask, credits, redeemed_by, redeemed_email, redeemed_at)
|
||||
values ('${codeId}', '${"b".repeat(64)}', 'JYOTISH-****-USED', 3, '${userId}', 'admin@example.com', now())
|
||||
`);
|
||||
const immutable = await admin.rpc("admin_update_redemption_code", {
|
||||
...rpcArgs("update-used"),
|
||||
p_code_id: codeId,
|
||||
p_set_note: true,
|
||||
p_note: "changed",
|
||||
p_set_expires_at: false,
|
||||
p_expires_at: null,
|
||||
});
|
||||
assert.ok(immutable.error);
|
||||
assert.equal(fixture.psql(`select note is null from public.redemption_codes where id = '${codeId}'`), "t");
|
||||
|
||||
fixture.psql(`
|
||||
create or replace function audit.test_fail_admin_audit()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
raise exception 'forced audit failure';
|
||||
end;
|
||||
$$;
|
||||
create trigger test_fail_admin_audit
|
||||
before insert on audit.admin_audit_logs
|
||||
for each row execute function audit.test_fail_admin_audit()
|
||||
`);
|
||||
const auditFailure = await admin.rpc("admin_create_redemption_codes", {
|
||||
...rpcArgs("create-audit-failure"),
|
||||
p_codes: [{
|
||||
codeHash: "c".repeat(64),
|
||||
codeMask: "JYOTISH-****-FAIL",
|
||||
credits: 7,
|
||||
expiresAt: null,
|
||||
note: "must rollback",
|
||||
}],
|
||||
});
|
||||
assert.ok(auditFailure.error);
|
||||
assert.equal(
|
||||
fixture.psql("select count(*) from public.redemption_codes where code_mask = 'JYOTISH-****-FAIL'"),
|
||||
"0",
|
||||
"audit failure must leave the redemption code unchanged",
|
||||
);
|
||||
fixture.psql("drop trigger test_fail_admin_audit on audit.admin_audit_logs");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -228,3 +228,23 @@ test("database admin authorizer requires a current persisted admin role", async
|
||||
assert.match(queries[0].sql, /from identity\.users/);
|
||||
assert.deepEqual(queries[0].values, ["admin"]);
|
||||
});
|
||||
|
||||
test("admin surface authorizer allows persisted admin and viewer roles", async () => {
|
||||
const { createDatabaseAdminSurfaceAuthorizer } = await import("../src/modules/identity/auth.ts");
|
||||
const rowsByUser = new Map<string, Record<string, unknown>>([
|
||||
["admin", { role: "admin", banned: false, ban_expires: null }],
|
||||
["viewer", { role: "user,viewer", banned: false, ban_expires: null }],
|
||||
["user", { role: "user", banned: false, ban_expires: null }],
|
||||
]);
|
||||
const pool = {
|
||||
async query(_sql: string, values: unknown[]) {
|
||||
const row = rowsByUser.get(String(values[0]));
|
||||
return { rows: row ? [row] : [] };
|
||||
},
|
||||
} as unknown as Pool;
|
||||
const authorize = createDatabaseAdminSurfaceAuthorizer(pool);
|
||||
|
||||
assert.equal(await authorize("admin"), true);
|
||||
assert.equal(await authorize("viewer"), true);
|
||||
assert.equal(await authorize("user"), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user