Files
festipod/backend/services/public-read-acl.test.js
Sylvain Duchesne 88f23b7023 Harden the backend's trust surface: an allow-listed public surface, decisions taken on data re-read from the pod
The backend served its settings dataset (including the app actor's private key) to anonymous callers, through the SPARQL endpoint and through the semapps LDP catch-all route, since WebACL is off. An allow-list mounted on every route now exposes only what the app and the pod need (/app, /as/actor/app and its Client ID Document, /catalog, the public key, access needs, the JSON-LD context, nodeinfo, the app inbox and the webhook endpoint); everything else answers 404.

Every watcher-fed service (public read, catalog, attendees, contacts, registration) now decides on the activity and object re-read from the pod as the app, checks they belong to the outbox owner and that the stored object has the expected type, and never acts on the notification body. Decisions for one object are serialized and taken on a read made when acting, with a 30 s bound and an abort check before every write. The anonymous read follows the event's visibility both ways, attendees collection included; Festipod records the grants it makes and revokes only those, leaving any other public share untouched; an unreadable ACL changes nothing. A participant's attendance follows their latest act by the pod's timestamp, not the order notifications arrive. Known limits (no conditional write on the pod, a write in flight at the timeout) are documented in the code.
2026-10-13 16:30:52 +02:00

97 lines
5.4 KiB
JavaScript

const { test, expect, describe } = require('bun:test');
const { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, isPublicRead, FOAF_AGENT } = require('./public-read-acl');
const ACL = 'http://www.w3.org/ns/auth/acl#';
const ALICE = 'http://localhost:3000/alice';
const E = `${ALICE}/data/events/e1`;
const ACL_URI = `http://localhost:3000/_acl/alice/data/events/e1`;
const defaultRead = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agent': ALICE };
const publicRead = (agentClass = 'foaf:Agent') => ({ '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': agentClass });
// Flatten the PUT graph's agent triples for assertions.
const triples = doc => {
const out = [];
for (const n of doc['@graph']) for (const p of ['acl:agent', 'acl:agentClass', 'acl:agentGroup', 'acl:origin'])
for (const o of [].concat(n[p] ?? [])) out.push({ auth: n['@id'], p, o });
return out;
};
describe('grantsPublicRead / isPublicRead', () => {
test('detects the foaf:Agent read rule in both spellings', () => {
expect(grantsPublicRead([defaultRead, publicRead('foaf:Agent')])).toBe(true);
expect(grantsPublicRead([defaultRead, publicRead(FOAF_AGENT)])).toBe(true);
expect(grantsPublicRead([defaultRead, publicRead({ '@id': FOAF_AGENT })])).toBe(true);
});
test('a non-public ACL is not public-read', () => {
expect(grantsPublicRead([defaultRead])).toBe(false);
expect(isPublicRead({ '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agent': ALICE })).toBe(false);
});
});
describe('revocationGraph — a PUT body that drops foaf:Agent, keeps the rest, never empty', () => {
test('typical event (only own rule is #Read foaf:Agent): keeps owner, drops public, omits #Default', () => {
const doc = revocationGraph([defaultRead, publicRead('foaf:Agent')], E, ALICE, ACL_URI);
const ts = triples(doc);
expect(ts.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false);
expect(ts.some(t => t.auth.includes('#Default'))).toBe(false); // PUT must not touch inherited defaults
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE && t.auth.includes('#Read'))).toBe(true);
expect(doc['@context']['@base']).toBe(ACL_URI);
});
test('full foaf IRI is dropped too', () => {
const ts = triples(revocationGraph([publicRead(FOAF_AGENT)], E, ALICE, ACL_URI));
expect(ts.some(t => t.o === FOAF_AGENT || t.o === 'foaf:Agent')).toBe(false);
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE)).toBe(true); // owner fallback, not empty
});
test('a co-existing per-contact grant on #Read is preserved', () => {
const carol = 'http://localhost:3000/carol';
const node = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent', 'acl:agent': carol };
const ts = triples(revocationGraph([node], E, ALICE, ACL_URI));
expect(ts.some(t => t.p === 'acl:agent' && t.o === carol)).toBe(true);
expect(ts.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false);
// The owner fallback is NOT added when another agent already keeps the set non-empty.
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE)).toBe(false);
});
test('a separate own #Control rule is retained (only the public Read triple goes)', () => {
const control = { '@id': '#Control', 'acl:mode': 'acl:Control', 'acl:agent': ALICE };
const ts = triples(revocationGraph([control, publicRead('foaf:Agent')], E, ALICE, ACL_URI));
expect(ts.some(t => t.auth.includes('#Control') && t.o === ALICE)).toBe(true);
expect(ts.some(t => t.o === 'foaf:Agent')).toBe(false);
});
});
describe('revocationGraph is NARROW — only the public READ goes', () => {
test('a foaf:Agent on the own #Append rule (a world-appendable collection) is kept; only #Read loses it', () => {
const append = { '@id': '#Append', 'acl:mode': 'acl:Append', 'acl:agentClass': 'foaf:Agent' };
const ts = triples(revocationGraph([publicRead('foaf:Agent'), append], E, ALICE, ACL_URI));
expect(ts.some(t => t.auth === '#Append' && t.p === 'acl:agentClass' && t.o === 'foaf:Agent')).toBe(true);
expect(ts.some(t => t.auth === '#Read' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
});
test('a foaf:Agent on the own #Write rule is kept too, under either spelling', () => {
const write = { '@id': '#Write', 'acl:mode': 'acl:Write', 'acl:agentClass': FOAF_AGENT };
const ts = triples(revocationGraph([publicRead(FOAF_AGENT), write], E, ALICE, ACL_URI));
expect(ts.some(t => t.auth === '#Write' && t.o === FOAF_AGENT)).toBe(true);
expect(ts.some(t => t.auth === '#Read' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
});
});
describe('own versus inherited public read', () => {
const inheritedPublic = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
test('the own #Read rule is what a revocation can remove', () => {
expect(hasOwnPublicRead([defaultRead, publicRead()])).toBe(true);
expect(hasInheritedPublicRead([defaultRead, publicRead()])).toBe(false);
});
test('a public read inherited from the container is told apart (a PUT cannot remove it)', () => {
expect(hasOwnPublicRead([inheritedPublic])).toBe(false);
expect(hasInheritedPublicRead([inheritedPublic])).toBe(true);
expect(grantsPublicRead([inheritedPublic])).toBe(true);
});
test('a foaf:Agent on another own rule is not a public read', () => {
expect(hasOwnPublicRead([{ '@id': '#Append', 'acl:mode': 'acl:Append', 'acl:agentClass': 'foaf:Agent' }])).toBe(false);
});
});