openvibers/openvibe.services Commits

Services: suspend, resume and delete from the resources page (ADR-048 control, as the person) (#56)

OP OpenVibers committed 30 days ago · 61e8dba59c · 3 files, +93 −3

Changes

ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex e0cc33b..08b7634 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -46,6 +46,8 @@ const BUDGETS = {
'services.app.manage': { minute: 30, hour: 300 },
// Rotating or revoking a credential mints or kills a secret: a few at a time.
'services.app.credential': { minute: 10, hour: 60 },
++ // Suspend, resume or delete from the resources page (each one reaches the service that holds the resource).
++ 'services.resource.control': { minute: 20, hour: 200 },
// A playground run calls Events or Media with the app's own credential: above the runs an hour
// per person (SERVICES_PLAYGROUND_RUNS_PER_HOUR, 60), which keeps deciding; refusals count.
'services.playground.run': { minute: 10, hour: 120 },
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex f292c7d..ce96cc1 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -20,6 +20,7 @@
* - resources are what each authority answered just now; an authority that could not be read is named
* on the page with the problem it gave, never left out silently
*/
++const crypto = require('crypto');
const express = require('express');
const { asyncRouter } = require('./router');
const cache = require('openvibe-shared/cache-policy');
@@@ -358,7 +359,7 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
* the signed-in person's own resources (`owner`: the ones in no project, each authority filtering by its own owner
* column, server/resources.js keeping the page to them).
*/
-- async function resourcesPage(req, res, { scope, base, title, crumbs, heading, intro, empty, footer, ownerColumn }) {
++ async function resourcesPage(req, res, { scope, base, title, crumbs, heading, intro, empty, footer, ownerColumn, controls = null }) {
const services = ctx.index.adapters.map((a) => a.authority.id).sort();
const q = { service: req.query.service, kind: req.query.kind, cursor: req.query.cursor };
let filter = { service: null, kind: null, cursor: null };
@@@ -382,12 +383,22 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
if (o.type === 'user' && o.id === me) return 'you';
return html`<code class="small">${o.id}</code>`;
};
++ // Control (ADR-048, plan T13 step 10): an admin or owner acts on what they own, at a service that takes control
++ // requests (its manifest lists an active <id>.resource.control). The service decides; Services shows its answer.
++ const controllable = new Set(ctx.index.adapters.filter((a) => a.authority.control).map((a) => a.authority.id));
++ const c = controls ? csrf(req) : null;
++ const actionsCell = (x) => {
++ if (!controls || !x.ovrn || !controllable.has(x.service) || !(x.owner && x.owner.type === 'user' && x.owner.id === me)) return html`<span class="muted">—</span>`;
++ const act = (action, label) => html`<form method="post" action="/projects/${controls.project.id}/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="resource" value="${x.ovrn}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;
++ return html`${x.state === 'paused' ? act('resume', 'Resume') : act('suspend', 'Suspend')} ${act('delete', 'Delete…')}`;
++ };
const rows = result ? result.resources.map((x) => [
html`${x.name || x.id}<br><code class="small">${x.ovrn || x.id}</code>`,
html`<a href="${link({ kind: x.kind, service: null })}"><code>${x.kind}</code></a>`,
stateBadge(x.state),
...(ownerColumn ? [ownerCell(x.owner)] : []),
time(x.created_at), time(x.updated_at),
++ ...(controls ? [actionsCell(x)] : []),
]) : [];
const partial = result ? result.partial : [];
const filtered = Boolean(filter.service || filter.kind);
@@@ -401,7 +412,8 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
<button type="submit">Show</button>${filtered ? html` <a href="${base}">Clear</a>` : ''}</form>
${refused ? notice(html`That filter was refused: ${refused.detail || refused.message} (<code>${refused.code}</code>).`, 'bad') : ''}
${partial.length ? notice(html`Not on this page: ${partial.map((p, i) => html`${i ? ', ' : ''}<strong>${p.service}</strong> (<code>${p.code}</code>)`)} could not be read just now. Everything else is here; reload to try ${partial.length === 1 ? 'it' : 'them'} again.`, 'warn') : ''}
--${result ? table(['Resource', 'Kind', 'State', ...(ownerColumn ? ['Owner'] : []), 'Created', 'Updated'], rows, {
++${req.query.done ? notice(String(req.query.done).slice(0, 200), 'ok') : ''}
++${result ? table(['Resource', 'Kind', 'State', ...(ownerColumn ? ['Owner'] : []), 'Created', 'Updated', ...(controls ? ['Actions'] : [])], rows, {
empty: filtered ? 'No resources match this filter.' : empty,
}) : ''}
${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? html`<a href="${link({})}">First page</a>` : ''}${filter.cursor && result.next_cursor ? ' · ' : ''}${result.next_cursor ? html`<a href="${link({ cursor: result.next_cursor })}">Next page</a>` : ''}</p>` : ''}
@@@ -418,11 +430,50 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
heading: html`<h1>Resources <small>${project.name}</small></h1>`,
intro: 'Each OpenVibe service answers for its own resources in this project, read just now and merged here. Services shows them; the service that holds each one owns it.',
empty: 'No resources in this project yet. Releases, Media objects, event subscriptions and the other things apps in this project create appear here.',
-- footer: html`What you own outside any project (robots, chat rooms, streams) is on <a href="/projects/mine">your resources</a>. Apps read this list with <a href="/docs/capabilities/services.resource.read"><code>services.resource.read</code></a>: <code>GET /api/v1/resources?project=${project.id}</code>.`,
++ footer: html`What you own outside any project (robots, chat rooms, streams) is on <a href="/projects/mine">your resources</a>. Apps read this list with <a href="/docs/capabilities/services.resource.read"><code>services.resource.read</code></a>: <code>GET /api/v1/resources?project=${project.id}</code>. Suspend, resume and delete go to the service that holds the resource (<a href="/docs/capabilities/services.resource.control"><code>services.resource.control</code></a>), for what you own here as an admin or owner.`,
ownerColumn: true,
++ controls: atLeast(project.role, 'admin') ? { project } : null,
});
});
++ // One control operation from the console (ADR-048): the person's own request, made through server/domain/control.js
++ // exactly as POST /api/v1/resources/control makes it (idempotency, the sensitive-action gate, the log and audit).
++ // Delete asks first. The service that holds the resource decides; its answer is shown as it is.
++ const CONSOLE_ACTIONS = { suspend: 'Suspended', resume: 'Resumed', delete: 'Deleted' };
++ r.post('/:project/resources/control', idParams, B('services.resource.control'), form, guard, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const back = `/projects/${project.id}/resources`;
++ if (!atLeast(project.role, 'admin')) return page(req, res, { title: 'Not allowed', body: html`<h1>Not allowed</h1><p>Only an admin or owner of this project acts on its resources here.</p><p><a href="${back}">Back</a></p>` }, 403);
++ const b = req.body || {};
++ const action = String(b.action || '');
++ const resource = String(b.resource || '').slice(0, 300);
++ if (!CONSOLE_ACTIONS[action]) return page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>Suspend, resume or delete.</p><p><a href="${back}">Back</a></p>` }, 422);
++ if (action === 'delete' && b.confirm !== 'yes') {
++ return page(req, res, {
++ title: 'Delete?', crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back }, { label: 'Delete' }],
++ body: html`<h1>Delete this resource?</h1><p><code>${resource}</code></p><p>The service that holds it deletes it. This cannot be undone here.</p>
++<form method="post" action="/projects/${project.id}/resources/control" class="inline-form">${csrfField(csrf(req))}<input type="hidden" name="resource" value="${resource}"><input type="hidden" name="action" value="delete"><input type="hidden" name="confirm" value="yes"><button type="submit">Delete it</button></form> <a href="${back}">Cancel</a>`,
++ });
++ }
++ const request = { action, resource, project_id: project.id, idempotency_key: `portal-${crypto.randomBytes(12).toString('hex')}` };
++ let result;
++ try {
++ const check = require('openvibe-contracts').resources.checkControlRequest(request);
++ if (!check.valid) throw new ServiceError(400, 'control.bad_request', 'that is not a resource of this project');
++ result = await ctx.control.run(request, { kind: 'user', subject: req.viewer.subject });
++ } catch (err) {
++ if (!(err instanceof ServiceError)) throw err;
++ return page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>${err.detail || err.message} (<code>${err.code}</code>)</p><p><a href="${back}">Back</a></p>` }, err.status || 400);
++ }
++ if (result.state === 'done' || result.state === 'pending') {
++ return res.redirect(303, `${back}?done=${encodeURIComponent(`${CONSOLE_ACTIONS[action]}${result.state === 'pending' ? ' (the service finishes it shortly)' : ''}: ${resource}`)}`);
++ }
++ const why = result.confirmation_required ? `It needs its owner's approval first (${result.confirmation_required.reason}).`
++ : result.problem ? `${result.problem.detail || result.problem.title || ''} (${result.problem.code || result.problem.status})` : result.state;
++ return page(req, res, { title: 'Not done', body: html`<h1>The service did not do it</h1><p>${why}</p><p><a href="${back}">Back to the resources</a></p>` }, 200);
++ });
++
// ── Usage (WS-N task 4): Network's per-day numbers from the services' rollups ──
r.get('/:project/usage', idParams, async (req, res) => {
const project = await loadProject(req, res);
ddiff --git a/test/control.test.js b/test/control.test.js
iindex 4478941..8a84291 100644
---- a/test/control.test.js
++++ b/test/control.test.js
@@@ -16,11 +16,14 @@ const { boot, check, done } = require('./helpers/boot');
async function startAuthority() {
const seen = [];
let network = null;
++ let listed = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
++ // The index, for the console's resources page: what `listed` holds.
++ if (req.method === 'GET' && req.url.startsWith('/api/v1/resources')) return send(200, { resources: listed, next_cursor: null });
if (req.url !== '/api/v1/resources/control' || req.method !== 'POST') return send(404, { type: 'about:blank', title: 'Not found', status: 404, code: 'route.not_found' });
const token = String(req.headers.authorization || '').replace(/^Bearer /, '');
const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.media' });
@@@ -46,6 +49,7 @@ async function startAuthority() {
return {
url: `http://127.0.0.1:${server.address().port}`, seen,
bind(n) { network = n; },
++ list(rows) { listed = rows; },
close: () => new Promise((r) => server.close(r)),
};
}
@@@ -230,6 +234,39 @@ function registryOf(list) {
assert.strictEqual(row.actor_subject, `user:${owner.subject}`);
});
++ await check('the console: an admin suspends what they own from the resources page; delete asks first; a viewer sees no actions', async () => {
++ const owner = t.network.addUser('console-owner');
++ const viewer = t.network.addUser('console-viewer');
++ const project = await t.project(owner, 'Console project');
++ t.network.state.members.get(project).set(viewer.subject, 'viewer');
++ const mine = resources.format({ service: 'media', project_id: project, type: 'object', id: ids.newId('media') });
++ const now = new Date().toISOString();
++ media.list([{ id: mine.split('/').pop(), kind: 'media.object', service: 'media', project_id: project, owner: { type: 'user', id: owner.subject },
++ name: 'clip.mp4', state: 'active', created_at: now, updated_at: now, ovrn: mine }]);
++ const page = await t.get(`/projects/${project}/resources`, { as: owner });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('<th>Actions</th>') || page.text.includes('Actions'), 'an Actions column');
++ assert.ok(page.text.includes('value="suspend"') && page.text.includes('value="delete"'));
++ const seenBefore = media.seen.length;
++ const suspended = await t.get(`/projects/${project}/resources/control`, { as: owner, form: { resource: mine, action: 'suspend' } });
++ assert.strictEqual(suspended.status, 303, suspended.text.slice(0, 300));
++ assert.match(suspended.headers.get('location'), /done=Suspended/);
++ const sent = media.seen.at(-1).body;
++ assert.deepStrictEqual([sent.action, sent.resource, sent.project_id], ['suspend', mine, project]);
++ assert.deepStrictEqual(sent.on_behalf_of, { type: 'user', id: owner.subject });
++ const ask = await t.get(`/projects/${project}/resources/control`, { as: owner, form: { resource: mine, action: 'delete' } });
++ assert.strictEqual(ask.status, 200);
++ assert.ok(ask.text.includes('Delete this resource?'));
++ assert.strictEqual(media.seen.length, seenBefore + 1, 'asking to delete sends nothing');
++ const deleted = await t.get(`/projects/${project}/resources/control`, { as: owner, form: { resource: mine, action: 'delete', confirm: 'yes' } });
++ assert.strictEqual(deleted.status, 303);
++ assert.strictEqual(media.seen.at(-1).body.action, 'delete');
++ const theirs = await t.get(`/projects/${project}/resources`, { as: viewer });
++ assert.ok(!theirs.text.includes('value="suspend"'), 'a viewer gets no controls');
++ assert.strictEqual((await t.get(`/projects/${project}/resources/control`, { as: viewer, form: { resource: mine, action: 'suspend' } })).status, 403);
++ media.list([]);
++ });
++
await check('one audit row per accepted request (a replay too); neither table holds params or a token', async () => {
const before = (await audits()).length;
const k = key();