Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Fixture for record-scoped WRITE authorization (write-side corollary to #1786).
graphqlSchema:
files: '*.graphql'
jsResource:
files: resources.js
rest: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Fixture for record-scoped WRITE authorization (write-side corollary to #1786).
//
// The recommended composition pattern for record-scoped write overrides:
// - Gate on the base table/RBAC grant via `super.allow*(...)` first, so a user who loses the
// role's table write permission is denied regardless of ownership.
// - Per record (`this` = a per-row resource for delete / the element's resource for array puts,
// with schema properties readable through it), additionally require the requesting user to
// own the row. For a create there is no existing row: the incoming record is the parameter.
//
// Super users always pass so that seed writes and setup ops succeed.

function isSuper(user) {
return !!user?.role?.permission?.super_user;
}

export class Vault extends tables.Vault {
allowDelete(user, target, context) {
if (isSuper(user)) return true;
if (!super.allowDelete(user, target, context)) return false;
const owner = this?.owner;
// No loaded record (a single-record entry check on a missing row): RBAC passed, allow.
if (owner === undefined || owner === null) return true;
// Per-row: only the owner may delete this specific record.
return owner === user?.username;
}

allowUpdate(user, _record, _context) {
if (isSuper(user)) return true;
// `this` = the resource with the EXISTING record loaded; only the owner may overwrite it.
return this?.owner === user?.username;
}

allowCreate(user, record, _context) {
if (isSuper(user)) return true;
// New record: it must be created under the requesting user's own ownership.
return record?.owner === user?.username;
}

allowRead() {
return true; // reads are intentionally open so the test can verify surviving rows per user
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Fixture for record-scoped WRITE authorization (write-side corollary to #1786).
#
# Vault rows are owned by a specific user (`owner` field). The allow* overrides in resources.js
# permit a write/delete only when the record's owner matches the requesting user. The test
# verifies that a conditional REST DELETE only removes the caller's own rows (filter semantics)
# and that an array PUT containing a foreign element fails atomically.
type Vault @table @export {
id: ID @primaryKey
owner: String
kind: String @indexed
secret: String
}
149 changes: 149 additions & 0 deletions integrationTests/security/record-row-allowwrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Record-scoped WRITE authorization (write-side corollary to #1786's record-scoped allowRead).
*
* An application-overridden allowDelete on a query-shaped REST DELETE is evaluated once per
* matching record (FILTER semantics: the caller's own rows are deleted, everyone else's
* survive — a 200, not a blanket 403). An array PUT is authorized per element (create-vs-update
* chosen by that element's existence) with FAIL semantics: one denied element rejects the whole
* request and the aborted transaction commits nothing.
*
* Fixture: `record-row-allowwrite` — Vault table with owner-scoped allowDelete/allowUpdate/
* allowCreate composing the base RBAC grant via `super.allowDelete`.
*
* Reproduction:
* npm run test:integration -- "integrationTests/security/record-row-allowwrite.test.ts"
*/
import { suite, test, before, after } from 'node:test';
import { deepStrictEqual, ok, strictEqual } from 'node:assert';
import { resolve } from 'node:path';

import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';
// @ts-expect-error utils/client.mjs has no type declarations; runtime resolves fine
import { createApiClient, createHeaders } from '../apiTests/utils/client.mjs';

const FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures/record-row-allowwrite');
const skipSuite = process.env.HARPER_RUNTIME === 'bun' || process.platform === 'win32';

const ALICE = { username: 'write_auth_alice', password: 'Alice-pw-write!' };
const BOB = { username: 'write_auth_bob', password: 'Bobby-pw-write!' };
const ROLE = 'write_auth_role';

suite('record-scoped write authorization over REST', { skip: skipSuite }, (ctx: ContextWithHarper) => {
let client: ReturnType<typeof createApiClient>;
let restURL = '';
let aliceHeaders: Record<string, string>;

async function seed(rows: any[]) {
await client.req().send({ operation: 'insert', schema: 'data', table: 'Vault', records: rows }).expect(200);
}
async function idsOf(kind: string): Promise<string[]> {
const resp = await fetch(`${restURL}/Vault/?kind=${kind}`, { headers: aliceHeaders });
strictEqual(resp.status, 200);
const rows = await resp.json();
return rows.map((row: any) => row.id).sort();
}

before(async () => {
await setupHarperWithFixture(ctx, FIXTURE_PATH, { config: {}, env: {} });
client = createApiClient(ctx.harper);
restURL = ctx.harper.httpURL;

const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const probe = await client.reqRest('/Vault/').timeout(3000);
if (probe.status !== 404) break;
} catch {
/* not ready */
}
await new Promise((r) => setTimeout(r, 250));
}

await client
.req()
.send({
operation: 'add_role',
role: ROLE,
permission: {
super_user: false,
data: {
tables: {
Vault: { read: true, insert: true, update: true, delete: true, attribute_permissions: [] },
},
},
},
})
.expect(200);

for (const u of [ALICE, BOB]) {
await client
.req()
.send({ operation: 'add_user', role: ROLE, username: u.username, password: u.password, active: true })
.expect(200);
}

aliceHeaders = createHeaders(ALICE.username, ALICE.password);
});

after(async () => {
await teardownHarper(ctx);
});

test('conditional DELETE filters per record: only the caller-owned rows are deleted (200)', async () => {
await seed([
{ id: 'del-a1', owner: ALICE.username, kind: 'del', secret: 'a1' },
{ id: 'del-a2', owner: ALICE.username, kind: 'del', secret: 'a2' },
{ id: 'del-b1', owner: BOB.username, kind: 'del', secret: 'b1' },
{ id: 'del-b2', owner: BOB.username, kind: 'del', secret: 'b2' },
]);
const resp = await fetch(`${restURL}/Vault/?kind=del`, { method: 'DELETE', headers: aliceHeaders });
ok(resp.status < 300, `conditional DELETE should succeed with filter semantics, got ${resp.status}`);
deepStrictEqual(await idsOf('del'), ['del-b1', 'del-b2'], "bob's rows must survive alice's query delete");
});

test('array PUT with a foreign element fails whole (403) and commits nothing', async () => {
await seed([
{ id: 'put-a1', owner: ALICE.username, kind: 'put', secret: 'original' },
{ id: 'put-b1', owner: BOB.username, kind: 'put', secret: 'original' },
]);
const resp = await fetch(`${restURL}/Vault/`, {
method: 'PUT',
headers: { ...aliceHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify([
{ id: 'put-a1', owner: ALICE.username, kind: 'put', secret: 'updated' },
{ id: 'put-b1', owner: BOB.username, kind: 'put', secret: 'updated' },
]),
});
strictEqual(resp.status, 403, `a denied element must fail the whole array PUT, got ${resp.status}`);
const rows = await (await fetch(`${restURL}/Vault/?kind=put`, { headers: aliceHeaders })).json();
strictEqual(rows.length, 2, 'both seeded rows must still exist');
ok(
rows.every((row: any) => row.secret === 'original'),
`the aborted transaction must not commit ANY element (including the allowed one): ${JSON.stringify(rows)}`
);
});

test('all-owned array PUT succeeds per element, including an owned create', async () => {
await seed([{ id: 'ok-a1', owner: ALICE.username, kind: 'ok', secret: 'original' }]);
const resp = await fetch(`${restURL}/Vault/`, {
method: 'PUT',
headers: { ...aliceHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify([
{ id: 'ok-a1', owner: ALICE.username, kind: 'ok', secret: 'updated' }, // existing → allowUpdate
{ id: 'ok-a2', owner: ALICE.username, kind: 'ok', secret: 'new' }, // new → allowCreate
]),
});
ok(resp.status < 300, `an all-owned array PUT must succeed, got ${resp.status}`);
deepStrictEqual(await idsOf('ok'), ['ok-a1', 'ok-a2']);
});

test('array PUT claiming foreign ownership on a NEW row is denied by allowCreate', async () => {
const resp = await fetch(`${restURL}/Vault/`, {
method: 'PUT',
headers: { ...aliceHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify([{ id: 'new-b1', owner: BOB.username, kind: 'new', secret: 'forged' }]),
});
strictEqual(resp.status, 403, `creating a row under someone else's ownership must be denied, got ${resp.status}`);
deepStrictEqual(await idsOf('new'), [], 'the denied create must not exist');
});
});
Loading