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.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// The one WAC shape Festipod adds to make a pod resource world-readable — `acl:Read` for
|
||||
// `acl:agentClass foaf:Agent` — plus how to read whether it is present and how to take exactly
|
||||
// that shape away again.
|
||||
//
|
||||
// How the pod's ACL API behaves (@semapps/webacl 1.2.7, read in setRights.ts + utils.ts, confirmed
|
||||
// live 2026-10-12): `PATCH {aclUri}` only ADDS triples; `PUT {aclUri}` REPLACES the resource's own
|
||||
// rights, computed as a diff over AGENT triples (`acl:agent`, `acl:agentClass`, `acl:agentGroup`,
|
||||
// `acl:origin`) keyed by the resource's own `#Read`/`#Write`/`#Append`/`#Control` authorizations.
|
||||
// Inherited `#Default*` rules come from the container, are not the resource's own, and are left
|
||||
// untouched by a PUT. An EMPTY own rule set is refused (400 "PUT rights cannot be empty").
|
||||
//
|
||||
// A Festipod event's only OWN rule is `#Read` with `acl:agentClass foaf:Agent` (the grant this
|
||||
// backend adds); the owner reads through the container's `#Default` rules, not an own rule. So
|
||||
// removing the foaf:Agent triple alone would leave the own set empty and the PUT would be refused.
|
||||
// `revocationGraph` therefore keeps the owner as `acl:agent` of `#Read` when nothing else remains —
|
||||
// the owner already reads via the default, so no right is gained or lost — and preserves any other
|
||||
// own agent triples (e.g. a future per-contact grant) unchanged.
|
||||
//
|
||||
// NARROW: only the public READ goes. The pod keys own rules by mode (`#Read`, `#Write`, `#Append`,
|
||||
// `#Control`); the `foaf:Agent` triple is removed from the `#Read` rule alone, so a `foaf:Agent` on
|
||||
// another own rule (a world-appendable collection, say) is left exactly as it was.
|
||||
//
|
||||
// INHERITED public read cannot be taken away here: a `#Default*` rule of the container is not the
|
||||
// resource's own, and a PUT on the resource's ACL leaves it in place. `hasOwnPublicRead` and
|
||||
// `hasInheritedPublicRead` tell the two apart so the caller can say which one it met.
|
||||
//
|
||||
// The platform's own `pod-permissions.remove` cannot do this: it matches the full foaf IRI against
|
||||
// the compacted `foaf:Agent` the pod returns (removes nothing), and it drops any authorization
|
||||
// whose removed predicate becomes empty even when other agent predicates remain (would empty the
|
||||
// set → 400). This module PUTs a correct graph instead.
|
||||
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
|
||||
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
||||
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
||||
const FOAF_AGENT = FOAF + 'Agent';
|
||||
const OWN_SUFFIXES = ['Read', 'Write', 'Append', 'Control'];
|
||||
const AGENT_PREDICATES = ['agent', 'agentClass', 'agentGroup', 'origin'];
|
||||
|
||||
/** Expands `acl:`/`foaf:` CURIEs and `{ '@id' }` / `{ id }` nodes to full IRIs; passes others through. */
|
||||
const expand = value => {
|
||||
const id = typeof value === 'object' && value !== null ? value['@id'] || value.id : value;
|
||||
if (typeof id !== 'string') return id;
|
||||
if (id.startsWith('acl:')) return ACL + id.slice('acl:'.length);
|
||||
if (id.startsWith('foaf:')) return FOAF + id.slice('foaf:'.length);
|
||||
return id;
|
||||
};
|
||||
|
||||
/** The id an authorization node carries (`@id` or `id`). */
|
||||
const authId = authorization =>
|
||||
(authorization && (authorization['@id'] || authorization.id)) || '';
|
||||
|
||||
/** The values of `acl:<name>` on an authorization, under its compacted or full-IRI key (raw, not expanded). */
|
||||
const rawValuesOf = (authorization, name) => [
|
||||
...arrayOf(authorization['acl:' + name]),
|
||||
...arrayOf(authorization[ACL + name])
|
||||
];
|
||||
|
||||
const expandedValuesOf = (authorization, name) => rawValuesOf(authorization, name).map(expand);
|
||||
|
||||
/** True when the authorization is a Read rule that lets any agent (foaf:Agent) read. */
|
||||
const isPublicRead = authorization =>
|
||||
expandedValuesOf(authorization, 'mode').includes(ACL + 'Read') &&
|
||||
expandedValuesOf(authorization, 'agentClass').includes(FOAF_AGENT);
|
||||
|
||||
/** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */
|
||||
const grantsPublicRead = authorizations => arrayOf(authorizations).some(isPublicRead);
|
||||
|
||||
/** The fragment of an authorization's id (`Read` for `…#Read`, `DefaultRead` for an inherited rule). */
|
||||
const suffixOf = authorization => String(authId(authorization)).split('#')[1];
|
||||
|
||||
/** An authorization is the resource's OWN (not an inherited `#Default*`) when its id ends `#<suffix>`. */
|
||||
const isOwnAuthorization = authorization => OWN_SUFFIXES.includes(suffixOf(authorization));
|
||||
|
||||
/** True when the resource's OWN `#Read` rule lets any agent (foaf:Agent) read — what `revocationGraph` removes. */
|
||||
const hasOwnPublicRead = authorizations =>
|
||||
arrayOf(authorizations).some(authorization => suffixOf(authorization) === 'Read' && isPublicRead(authorization));
|
||||
|
||||
/** True when a rule that is NOT the resource's own (an inherited `#Default*`) lets any agent read. */
|
||||
const hasInheritedPublicRead = authorizations =>
|
||||
arrayOf(authorizations).some(authorization => !isOwnAuthorization(authorization) && isPublicRead(authorization));
|
||||
|
||||
/**
|
||||
* aclContext(aclUri) — the JSON-LD context to PUT with a revocation graph: the `acl:`/`foaf:`
|
||||
* prefixes and the id-typed agent/accessTo/mode terms, based at `aclUri` so `#Read` resolves to the
|
||||
* authorization fragment. Mirrors the pod's own `getAclContext` so the PUT round-trips identically.
|
||||
*/
|
||||
const aclContext = aclUri => ({
|
||||
'@base': aclUri,
|
||||
acl: ACL,
|
||||
foaf: FOAF,
|
||||
'acl:agent': { '@type': '@id' },
|
||||
'acl:agentClass': { '@type': '@id' },
|
||||
'acl:agentGroup': { '@type': '@id' },
|
||||
'acl:origin': { '@type': '@id' },
|
||||
'acl:mode': { '@type': '@id' },
|
||||
'acl:accessTo': { '@type': '@id' }
|
||||
});
|
||||
|
||||
/**
|
||||
* revocationGraph(authorizations, resourceUri, ownerUri) → a JSON-LD document `{ '@context', '@graph' }`
|
||||
* to PUT on the resource's ACL so it keeps every one of its OWN agent triples EXCEPT
|
||||
* `acl:agentClass foaf:Agent` on its `#Read` authorization (a `foaf:Agent` on any other own rule stays). Inherited `#Default*` rules are not emitted
|
||||
* (a PUT does not touch them). When removing the public-read triple would leave the own set empty,
|
||||
* the owner is emitted as `acl:agent` of `#Read` so the PUT is accepted without changing any right.
|
||||
* `aclUri` is where the document will be PUT (used only for the context `@base`).
|
||||
*/
|
||||
const revocationGraph = (authorizations, resourceUri, ownerUri, aclUri) => {
|
||||
const graph = [];
|
||||
for (const authorization of arrayOf(authorizations)) {
|
||||
if (!isOwnAuthorization(authorization)) continue;
|
||||
const id = authId(authorization);
|
||||
const isReadRule = suffixOf(authorization) === 'Read';
|
||||
const node = { '@id': id, '@type': 'acl:Authorization', 'acl:accessTo': resourceUri };
|
||||
for (const mode of rawValuesOf(authorization, 'mode')) {
|
||||
node['acl:mode'] = node['acl:mode'] ? [].concat(node['acl:mode'], mode) : mode;
|
||||
}
|
||||
let kept = 0;
|
||||
for (const name of AGENT_PREDICATES) {
|
||||
const values = rawValuesOf(authorization, name).filter(
|
||||
value => !(isReadRule && name === 'agentClass' && expand(value) === FOAF_AGENT)
|
||||
);
|
||||
if (values.length > 0) {
|
||||
node['acl:' + name] = values.length === 1 ? values[0] : values;
|
||||
kept += values.length;
|
||||
}
|
||||
}
|
||||
if (kept > 0) graph.push(node);
|
||||
}
|
||||
// The pod refuses an empty own rule set; keep the owner as a reader (already true via the default).
|
||||
if (graph.length === 0) {
|
||||
graph.push({ '@id': '#Read', '@type': 'acl:Authorization', 'acl:accessTo': resourceUri, 'acl:mode': 'acl:Read', 'acl:agent': ownerUri });
|
||||
}
|
||||
return { '@context': aclContext(aclUri), '@graph': graph };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
grantsPublicRead,
|
||||
hasOwnPublicRead,
|
||||
hasInheritedPublicRead,
|
||||
revocationGraph,
|
||||
isPublicRead,
|
||||
expand,
|
||||
FOAF_AGENT,
|
||||
ACL
|
||||
};
|
||||
Reference in New Issue
Block a user