Skip to content

Commit 91be51b

Browse files
authored
Merge pull request #732 from devforth/feature/AdminForth/1934/list-rows-lose-their-primary-k
fix: forbid backendOnly on primary key AdminForth/1934/list-rows-lose…
2 parents 03589c4 + c6cd8b4 commit 91be51b

4 files changed

Lines changed: 114 additions & 0 deletions

File tree

‎adminforth/documentation/docs/tutorial/03-Customization/12-security.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ So to completely hide the email field from all users apart superadmins, you shou
296296
297297
So if you will configure the email column in user resource like this, only superadmin will be able to see emails, and only in the list view.
298298
299+
> ☝️ `backendOnly` cannot be set on a primary key column (neither `true` nor a function): frontend needs primary key values to build record links, so AdminForth refuses such config on startup.
300+
299301
## Blocking login attempts before credentials check
300302
301303
`auth.beforeLoginAttempt` hooks are called on every request to the login endpoint, before AdminForth looks the user up in the database.

‎adminforth/modules/configValidator.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,13 @@ export default class ConfigValidator implements IConfigValidator {
13521352

13531353
postProcessAfterDiscover(resource: AdminForthResource) {
13541354
resource.columns.forEach((column) => {
1355+
// checked after discovery, because primaryKey may come from the database schema.
1356+
// frontend builds record links from primary key values, so they must always reach it.
1357+
// a backendOnly function is rejected too: it can hide the key only for some users
1358+
if (column.primaryKey && column.backendOnly) {
1359+
throw new Error(`Resource "${resource.resourceId}" column "${column.name}" is a primary key and cannot be backendOnly`);
1360+
}
1361+
13551362
// if db/user says column is required in boolean, expand
13561363
if (typeof column.required === 'boolean') {
13571364
column.required = { create: column.required, edit: column.required };

‎adminforth/types/Common.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,7 @@ export interface AdminForthResourceColumnInputCommon {
10371037

10381038
/**
10391039
* if true field will !not be passed to UI under no circumstances, but will be presented in hooks
1040+
* Not allowed on primary key columns.
10401041
*/
10411042
backendOnly?: boolean,
10421043

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { mkdtempSync, rmSync } from 'node:fs';
2+
import { createRequire } from 'node:module';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { fileURLToPath, pathToFileURL } from 'node:url';
6+
7+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
8+
const applicationDir = path.resolve(currentDir, '../application');
9+
10+
// resolves to the BUILT package (adminforth/dist) the test application runs on;
11+
// rebuild it (`npx tsc` in adminforth/) after editing adminforth sources or the assertions below test stale code
12+
const require = createRequire(import.meta.url);
13+
// jest's resolver does not know the `node:sqlite` builtin, so it is required directly
14+
const { DatabaseSync } = require('node:sqlite');
15+
const adminforthEntry = require.resolve('adminforth', { paths: [applicationDir] });
16+
const { default: AdminForth } = await import(pathToFileURL(adminforthEntry).href);
17+
18+
// own fixture, so the primary key is discovered from a known schema
19+
const fixtureDir = mkdtempSync(path.join(os.tmpdir(), 'af-pk-backend-only-'));
20+
const dbPath = path.join(fixtureDir, 'db.sqlite');
21+
const db = new DatabaseSync(dbPath);
22+
db.exec(`
23+
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL, password_hash TEXT NOT NULL);
24+
INSERT INTO users VALUES (1, 'pk@example.test', 'hidden');
25+
`);
26+
db.close();
27+
28+
process.env.ADMINFORTH_SECRET ??= 'x'.repeat(64);
29+
30+
afterAll(() => {
31+
rmSync(fixtureDir, { recursive: true, force: true });
32+
});
33+
34+
// constructor validation and database discovery together, as on real startup
35+
async function startApp(idColumn: Record<string, unknown>) {
36+
// AdminForth allows one instance per process; each test needs a fresh one
37+
delete (globalThis as any).adminforth;
38+
const app = new AdminForth({
39+
baseUrl: '',
40+
auth: {
41+
usersResourceId: 'users',
42+
usernameField: 'email',
43+
passwordHashField: 'password_hash',
44+
},
45+
dataSources: [{ id: 'sqlite', url: `sqlite://${dbPath}` }],
46+
resources: [{
47+
resourceId: 'users',
48+
table: 'users',
49+
dataSource: 'sqlite',
50+
columns: [
51+
{ name: 'id', ...idColumn },
52+
{ name: 'email' },
53+
{ name: 'password_hash', backendOnly: true },
54+
],
55+
}],
56+
menu: [{ label: 'Users', resourceId: 'users' }],
57+
});
58+
await app.discoverDatabases();
59+
return app;
60+
}
61+
62+
async function listUsers(app: any) {
63+
const endpoints: any[] = [];
64+
app.restApi.registerEndpoints({ endpoint: (config: any) => endpoints.push(config) });
65+
const { handler } = endpoints.find((endpoint) => endpoint.path === '/get_resource_data');
66+
return handler({
67+
body: { resourceId: 'users', source: 'list', limit: 10, offset: 0, filters: [], sort: [] },
68+
adminUser: { pk: '1', username: 'pk@example.test', dbUser: {} },
69+
headers: {},
70+
query: {},
71+
cookies: {},
72+
requestUrl: '/get_resource_data',
73+
abortSignal: new AbortController().signal,
74+
});
75+
}
76+
77+
const pkBackendOnlyError = /column "id" is a primary key and cannot be backendOnly/;
78+
79+
// `primaryKey` is omitted, not set to undefined: undefined would override the discovered value on merge
80+
describe.each([
81+
['declared', { primaryKey: true }],
82+
['discovered from database', {}],
83+
])('%s primary key', (_, pkFlag) => {
84+
it.each([
85+
['true', true],
86+
['function returning true', () => true],
87+
['function returning false', () => false],
88+
])('rejects startup with backendOnly %s', async (_, backendOnly) => {
89+
await expect(startApp({ ...pkFlag, backendOnly })).rejects.toThrow(pkBackendOnlyError);
90+
});
91+
92+
it.each([
93+
['false', { backendOnly: false }],
94+
['omitted', {}],
95+
])('starts with backendOnly %s and keeps the id in list rows', async (_, backendOnlyFlag) => {
96+
const app = await startApp({ ...pkFlag, ...backendOnlyFlag });
97+
const result = await listUsers(app);
98+
99+
expect(result.data).toEqual([expect.objectContaining({ id: 1, email: 'pk@example.test' })]);
100+
expect(result.recordIds).toEqual([1]);
101+
// non-primary-key backendOnly column stays allowed and hidden
102+
expect(result.data[0]).not.toHaveProperty('password_hash');
103+
});
104+
});

0 commit comments

Comments
 (0)