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:
@@ -1,38 +1,39 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const CONFIG = require('../config/config');
|
||||
const { hasPublicIntent } = require('./public-intent');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { neverThrowingMatcher, verifiedEmit, uriOf, singleUriOf, hasAsType } = require('./emitted-activity');
|
||||
const { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, FOAF_AGENT } = require('./public-read-acl');
|
||||
const { recordGrant, forgetGrant, isGrantedByFestipod } = require('./public-read-ledger');
|
||||
|
||||
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
||||
const FOAF_AGENT = 'http://xmlns.com/foaf/0.1/Agent';
|
||||
|
||||
// The pod compacts ACL documents with the `acl:` and `foaf:` prefixes; accept both forms.
|
||||
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 'http://xmlns.com/foaf/0.1/' + id.slice('foaf:'.length);
|
||||
return id;
|
||||
/** The ACL document URI of a pod resource: `{origin}/_acl{path}` (the pod's `getAclUriFromResourceUri`). */
|
||||
const aclUriOf = resourceUri => {
|
||||
const { origin, pathname } = new URL(resourceUri);
|
||||
return `${origin}/_acl${pathname}`;
|
||||
};
|
||||
|
||||
/** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */
|
||||
const grantsPublicRead = authorizations =>
|
||||
arrayOf(authorizations).some(
|
||||
authorization =>
|
||||
arrayOf(authorization['acl:mode']).map(expand).includes(ACL + 'Read') &&
|
||||
arrayOf(authorization['acl:agentClass']).map(expand).includes(FOAF_AGENT)
|
||||
);
|
||||
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
|
||||
|
||||
/** The attendees collection an event names (compacted or full-IRI key), when it names exactly one. */
|
||||
const attendeesCollectionOf = event => singleUriOf(event['apods:attendees'] ?? event[APODS_ATTENDEES]);
|
||||
|
||||
// Festipod creates its events and meeting points public: their bindings declare visibility
|
||||
// 'public', and its create path addresses each one to the ActivityStreams Public collection
|
||||
// (`src/shared/refine/providers/activitypods.ts`). This service owns the one WAC shape that makes
|
||||
// a pod resource world-readable — `acl:Read` for `acl:agentClass foaf:Agent`, nothing more — and
|
||||
// applies it, acting as the app actor, to an `as:Event` a user creates ONLY when the created
|
||||
// object carries that public intent (`backend/services/public-intent.js`). It learns of creations
|
||||
// through the same channel as the catalog: the app's watcher on each user's outbox, which calls
|
||||
// `onCreate` for every `Create` of an `as:Event` — whichever client created it and whatever its
|
||||
// audience, which is why the intent check, not the channel, decides.
|
||||
// keeps it in step with the object's CURRENT public intent (`public-intent.js`), acting as the app
|
||||
// actor: an `as:Event` addressed to the Public collection is world-readable, one that is not is not.
|
||||
// Its attendees collection follows it both ways (the pod creates that collection world-readable).
|
||||
// It takes back ONLY the public reads Festipod gave — recorded in `public-read-ledger.js` — and leaves
|
||||
// any other one (the owner's, another app's) untouched.
|
||||
//
|
||||
// It learns of changes through the app's watcher on each user's outbox — the `Create` and `Update`
|
||||
// of any `as:Event`, whichever client made it and whatever its audience — and every notification
|
||||
// leads to the same `reconcile`: under the event's lock (`keyed-serializer.js`), read the event NOW,
|
||||
// then grant or revoke on the event and its collection. The notification itself only names the
|
||||
// object; whatever order notifications arrive or run in, the last action follows the newest read.
|
||||
module.exports = {
|
||||
name: 'public-read',
|
||||
mixins: [PodResourcesHandlerMixin],
|
||||
@@ -40,55 +41,309 @@ module.exports = {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
|
||||
},
|
||||
dependencies: ['pod-permissions'],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
// The mixin's default handlers would act on the activity's self-declared `actor` and on the
|
||||
// object embedded in the (unauthenticated) webhook body. Override the whole map: keep the
|
||||
// mixin's create/update matchers (made never-throwing), but every `onEmit` acts only on an
|
||||
// activity `verifiedEmit` accepts — re-read from the pod as the app, attributed to the owner of
|
||||
// the outbox the webhook fired on, its object an `as:Event` in that owner's pod — and even then
|
||||
// only as a pointer to the event `reconcile` reads again. No delete handler: when an event is
|
||||
// deleted its ACL goes with it.
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.onCreate(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
},
|
||||
update: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.update.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Update',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.onUpdate(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* Idempotent: reads the resource's ACL first and adds nothing when a public-read
|
||||
* authorization is already there. Resolves `true` when it added the grant, `false` when the
|
||||
* resource was already public; throws when the pod refuses the grant.
|
||||
* Brings the public read of an event of `actorUri`'s pod — and of its attendees collection — in
|
||||
* line with the event's current public intent. Serialized per event: the event is read when the
|
||||
* previous reconcile of the same event has finished, and that read decides. Resolves
|
||||
* `{ isPublic, targets }`, or undefined when the event could not be judged (gone, unreadable,
|
||||
* not an `as:Event`, not in the actor's pod). A failed grant or revoke is logged, never thrown.
|
||||
* A reconcile that timed out and then WROTE (a write in flight at the timeout that landed after
|
||||
* it) is followed by ONE fresh reconcile of the same event, which judges that late write against
|
||||
* the state as it then is; that re-decision schedules nothing more, and a timed-out reconcile that
|
||||
* wrote nothing late schedules nothing at all (see `keyed-serializer.js` `onLateSettle`).
|
||||
*/
|
||||
grant: {
|
||||
reconcile: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { resourceUri, actorUri } = ctx.params;
|
||||
|
||||
const authorizations = await ctx.call('pod-permissions.get', { uri: resourceUri, actorUri });
|
||||
if (authorizations && grantsPublicRead(authorizations)) {
|
||||
this.logger.info(`${resourceUri} is already publicly readable`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const added = await ctx.call('pod-permissions.add', {
|
||||
uri: resourceUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: FOAF_AGENT,
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
return this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal), {
|
||||
onLateSettle: result => {
|
||||
if (result && result.wroteLate) this.redecideOnce(ctx, resourceUri, actorUri);
|
||||
}
|
||||
});
|
||||
if (!added) throw new Error(`The pod refused the public-read grant on ${resourceUri}`);
|
||||
|
||||
this.logger.info(`Granted public read on ${resourceUri}`);
|
||||
}
|
||||
},
|
||||
/** `grantNow` outside any lock (see there). */
|
||||
grant: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
return this.grantNow(ctx, ctx.params.resourceUri, ctx.params.actorUri);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Records that Festipod created `resourceUri` (an event's attendees collection, right after it is
|
||||
* attached): whatever public read it has is Festipod's doing — the pod makes such collections
|
||||
* world-readable — so `revoke` may take it back.
|
||||
*/
|
||||
recordCreated: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { resourceUri } = ctx.params;
|
||||
await recordGrant(ctx, { dataset: CONFIG.MAIN_DATASET, uri: resourceUri });
|
||||
this.logger.info(`Recorded ${resourceUri}, created by Festipod, as Festipod's to make public or not`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
/** `revokeNow` outside any lock (see there). */
|
||||
revoke: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
return this.revokeNow(ctx, ctx.params.resourceUri, ctx.params.actorUri);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// The mixin's matchers fire only when these exist. Both take the object (or its URI) as a
|
||||
// POINTER: the decision is `reconcile`'s, on a read taken when acting. They never throw: the
|
||||
// watcher runs every matching handler of an activity in sequence without catching, inside a
|
||||
// webhook call the platform does not await.
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
const resourceUri = resource.id || resource['@id'];
|
||||
if (!hasPublicIntent(resource)) {
|
||||
this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`);
|
||||
return;
|
||||
}
|
||||
// The watcher runs every matching handler of an activity in sequence and does not catch: a
|
||||
// throw here would skip the handlers after this one, and the platform does not await the
|
||||
// webhook call that runs the watcher, so the rejection would also end the process. Log, never throw.
|
||||
await this.reconcileLogged(ctx, uriOf(resource), actorUri);
|
||||
},
|
||||
async onUpdate(ctx, resource, actorUri) {
|
||||
await this.reconcileLogged(ctx, uriOf(resource), actorUri);
|
||||
},
|
||||
async reconcileLogged(ctx, resourceUri, actorUri) {
|
||||
try {
|
||||
await this.actions.grant({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
if (!resourceUri || !actorUri) throw new Error('no event or no actor to reconcile');
|
||||
await this.actions.reconcile({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not grant public read on ${resourceUri}: ${e.message}`);
|
||||
this.logger.error(`Could not reconcile public read on ${resourceUri}: ${e.message}`);
|
||||
}
|
||||
},
|
||||
// The one re-decision after a late write: a plain serialized reconcile WITHOUT `onLateSettle`, so
|
||||
// whatever happens to it (even a timeout with a late write of its own) it schedules nothing more.
|
||||
async redecideOnce(ctx, resourceUri, actorUri) {
|
||||
try {
|
||||
await this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal));
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not re-decide public read on ${resourceUri} after a late write: ${e.message}`);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The resource's ACL as the pod serves it to the app: `{ status, authorizations }`, the
|
||||
* authorizations only when the pod answered 200. Unlike `pod-permissions.get`, which answers
|
||||
* `false` for ANY failure — indistinguishable from "no public read" — a failure here is reported,
|
||||
* so a caller can tell "not public" from "unknown". Read where `revoke` writes (`aclUriOf`).
|
||||
*/
|
||||
async readAcl(ctx, resourceUri, actorUri) {
|
||||
const { status, body } =
|
||||
(await ctx.call('pod-permissions.fetch', {
|
||||
url: aclUriOf(resourceUri),
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
actorUri
|
||||
})) || {};
|
||||
if (status !== 200 || !body || typeof body !== 'object') return { status: typeof status === 'number' ? status : 0 };
|
||||
const graph = body['@graph'] !== undefined ? body['@graph'] : body['@id'] || body.id ? [body] : [];
|
||||
return { status, authorizations: Array.isArray(graph) ? graph : [graph] };
|
||||
},
|
||||
/**
|
||||
* Makes `resourceUri` world-readable and records it as FESTIPOD's grant — only when its ACL was
|
||||
* read and shows no public read: a resource already world-readable (the owner's share, another
|
||||
* app's) is left as it is and never recorded, and when the ACL cannot be read nothing is recorded
|
||||
* or written (the next notification retries). The record is written BEFORE the grant, so a
|
||||
* failure in between never leaves an unrecorded Festipod grant behind (which `revoke` would then
|
||||
* never take back). `signal` (from the event's lock) is checked immediately before each write;
|
||||
* the one write past it is re-recording a grant that LANDED late (a fact, see the code).
|
||||
* Resolves `true` when it added the grant, `false` otherwise; throws on a refusal or an unreadable ACL.
|
||||
*/
|
||||
async grantNow(ctx, resourceUri, actorUri, signal, late = {}) {
|
||||
const aborted = () => !!(signal && signal.aborted);
|
||||
// A write that RETURNS once the time has run out may have landed after a newer decision read.
|
||||
const wrote = () => {
|
||||
if (aborted()) late.wrote = true;
|
||||
};
|
||||
const acl = await this.readAcl(ctx, resourceUri, actorUri);
|
||||
if (!acl.authorizations) throw new Error(`the ACL of ${resourceUri} could not be read (HTTP ${acl.status}): nothing granted or recorded`);
|
||||
if (grantsPublicRead(acl.authorizations)) {
|
||||
this.logger.info(`${resourceUri} is already publicly readable`);
|
||||
return false;
|
||||
}
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
if (aborted()) return false;
|
||||
await recordGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
if (aborted()) return false;
|
||||
const added = await ctx.call('pod-permissions.add', {
|
||||
uri: resourceUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: FOAF_AGENT,
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
if (!added) {
|
||||
if (!aborted()) await forgetGrant(ctx, { dataset, uri: resourceUri }).catch(() => {});
|
||||
throw new Error(`The pod refused the public-read grant on ${resourceUri}`);
|
||||
}
|
||||
if (aborted()) {
|
||||
late.wrote = true;
|
||||
// The grant landed after the time ran out, and a newer decision may have run meanwhile and
|
||||
// forgotten the record (it saw no public read yet). A grant Festipod made must stay on record —
|
||||
// this writes a FACT, not a decision — so the re-decision that follows (`onLateSettle`) can
|
||||
// judge it against the current state and take it back if the event is no longer public.
|
||||
await recordGrant(ctx, { dataset, uri: resourceUri });
|
||||
this.logger.warn(`Public read on ${resourceUri} landed after its time ran out: re-recorded, to be re-decided`);
|
||||
return true;
|
||||
}
|
||||
this.logger.info(`Granted public read on ${resourceUri}`);
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* The reverse of `grantNow`, for a grant FESTIPOD made (recorded in the ledger) and only for it:
|
||||
* a public read Festipod did not add — the owner's, another app's, the pod's — is left untouched
|
||||
* and logged. Removes ONLY the `acl:agentClass foaf:Agent` triple of the resource's own `#Read`
|
||||
* rule, leaving every other OWN authorization (a `foaf:Agent` on another mode included) and the
|
||||
* inherited owner defaults in place, then forgets the record. When the ACL cannot be read, the
|
||||
* state is unknown: nothing changes and the record is KEPT, so a later notification retries.
|
||||
* Idempotent: with the own public-read rule already gone, it only forgets the record. A public
|
||||
* read INHERITED from the container cannot be removed this way: it is reported as a warning. Not
|
||||
* usable via `pod-permissions.remove` (see `public-read-acl.js`): it PUTs a reconstructed
|
||||
* own-rights graph, acting as the app actor (the same actor `grant` writes as, which holds
|
||||
* `acl:Control` on the resource). `signal` is checked immediately before each write. Resolves
|
||||
* `true` when it removed the rule, `false` otherwise; throws when the pod refuses the change.
|
||||
*/
|
||||
async revokeNow(ctx, resourceUri, actorUri, signal, late = {}) {
|
||||
const aborted = () => !!(signal && signal.aborted);
|
||||
const wrote = () => {
|
||||
if (aborted()) late.wrote = true;
|
||||
};
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
const acl = await this.readAcl(ctx, resourceUri, actorUri);
|
||||
if (!(await isGrantedByFestipod(ctx, { dataset, uri: resourceUri }))) {
|
||||
if (acl.authorizations && grantsPublicRead(acl.authorizations)) {
|
||||
this.logger.info(`${resourceUri} is publicly readable, but not through a grant Festipod made: left untouched`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!acl.authorizations) {
|
||||
this.logger.warn(`The ACL of ${resourceUri} could not be read (HTTP ${acl.status}): Festipod's grant kept, a later notification retries`);
|
||||
return false;
|
||||
}
|
||||
const inherited = hasInheritedPublicRead(acl.authorizations);
|
||||
if (!hasOwnPublicRead(acl.authorizations)) {
|
||||
if (aborted()) return false;
|
||||
await forgetGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
if (inherited) {
|
||||
this.logger.warn(`${resourceUri} is publicly readable through a rule inherited from its container: not revocable here`);
|
||||
} else {
|
||||
this.logger.info(`${resourceUri} is not publicly readable: nothing to revoke`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const aclUri = aclUriOf(resourceUri);
|
||||
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUri);
|
||||
const app = await ctx.call('app.get');
|
||||
if (aborted()) return false;
|
||||
const { status } = await ctx.call('signature.proxy.query', {
|
||||
url: aclUri,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify(document),
|
||||
actorUri: app.id || app['@id']
|
||||
});
|
||||
if (status !== 204 && status !== 200) {
|
||||
throw new Error(`The pod refused to revoke the public-read grant on ${resourceUri} (HTTP ${status})`);
|
||||
}
|
||||
if (aborted()) {
|
||||
late.wrote = true; // the PUT landed after the time ran out: to be re-decided
|
||||
return true;
|
||||
}
|
||||
await forgetGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
|
||||
this.logger.info(`Revoked public read on ${resourceUri}`);
|
||||
if (inherited) {
|
||||
this.logger.warn(`${resourceUri} stays publicly readable through a rule inherited from its container`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// Runs under the event's lock (see `reconcile`). The read here is the one that decides. Once
|
||||
// `signal` is aborted (the work timed out and the lock moved on) it writes nothing more; it
|
||||
// resolves `wroteLate: true` when a write it had sent returned after that (see `reconcile`).
|
||||
async reconcileNow(ctx, resourceUri, actorUri, signal) {
|
||||
if (!isInActorPod(resourceUri, actorUri)) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: not in the pod of ${actorUri}`);
|
||||
return undefined;
|
||||
}
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri, actorUri });
|
||||
if (status === 404 || status === 410 || (status === 200 && hasAsType(body, 'Tombstone'))) {
|
||||
this.logger.info(`${resourceUri} is gone: its ACL went with it`);
|
||||
return undefined;
|
||||
}
|
||||
if (status !== 200 || !body || uriOf(body) !== resourceUri) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: the pod does not return it (HTTP ${status})`);
|
||||
return undefined;
|
||||
}
|
||||
if (!hasAsType(body, 'Event')) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: not an as:Event`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isPublic = hasPublicIntent(body);
|
||||
const targets = [resourceUri];
|
||||
const collectionUri = attendeesCollectionOf(body);
|
||||
if (collectionUri && isInActorPod(collectionUri, actorUri)) targets.push(collectionUri);
|
||||
else if (collectionUri) this.logger.warn(`${resourceUri} names an attendees collection outside its pod: left alone`);
|
||||
if (!isPublic) this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`);
|
||||
|
||||
const late = { wrote: false };
|
||||
for (const uri of targets) {
|
||||
if (signal && signal.aborted) break;
|
||||
try {
|
||||
if (isPublic) await this.grantNow(ctx, uri, actorUri, signal, late);
|
||||
else await this.revokeNow(ctx, uri, actorUri, signal, late);
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not ${isPublic ? 'grant' : 'revoke'} public read on ${uri}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { isPublic, targets, wroteLate: late.wrote };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user