openvibers/openvibe.zone Commits

Add personal sharing defaults for Zone buckets (#209)

OP OpenVibers committed 2 days ago · 80f2aafe9e · 8 files, +180 −6

Changes

ddiff --git a/migrations/0007_zone_share_defaults.sql b/migrations/0007_zone_share_defaults.sql
nnew file mode 100644
iindex 0000000..6aa013a
---- /dev/null
++++ b/migrations/0007_zone_share_defaults.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- A person's choice for the console audience of named buckets they create.
++-- Storage access remains governed by the project's S3 keys.
++CREATE TABLE IF NOT EXISTS zone_share_defaults (
++ subject text PRIMARY KEY,
++ audience text NOT NULL DEFAULT 'private' CHECK (audience IN ('private', 'people')),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex baaae18..8182fc3 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -19,14 +19,17 @@ const TABLES = [];
* column, so they are exported and erased here, and the guard knows them by reason.
*/
async function extraExport(db, subject) {
++ const defaults = await db.many('SELECT * FROM zone_share_defaults WHERE subject = $1', [subject]);
const owned = await db.many("SELECT * FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['zone', subject]);
const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['zone', subject]);
const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['zone', subject]);
-- return [{ name: 'shares.json', content: { owned, member_of: member, requests: asked } }];
++ return [{ name: 'shares.json', content: { owned, member_of: member, requests: asked } },
++ { name: 'share-defaults.json', content: defaults }];
}
async function extraErase(t, subjects) {
for (const subject of subjects) {
++ await t.exec('DELETE FROM zone_share_defaults WHERE subject = $1', [subject]);
await t.exec("DELETE FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['zone', subject]);
await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['zone', subject]);
await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['zone', subject]);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex d45c236..94bf29b 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -24,6 +24,7 @@ const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { ZoneError } = require('../zone/zones');
const { ClientError } = require('../zone/clients');
++const { ShareError } = require('openvibe-sdk/share');
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -38,7 +39,7 @@ function createApi(ctx) {
const send = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
const wrap = (fn) => async (req, res) => {
try { await fn(req, res); } catch (err) {
-- if (err instanceof ZoneError || err instanceof ClientError) return send(req, res, err.status, err.code, err.detail || err.message);
++ if (err instanceof ZoneError || err instanceof ClientError || err instanceof ShareError) return send(req, res, err.status, err.code, err.detail || err.message);
throw err;
}
};
@@@ -83,6 +84,20 @@ function createApi(ctx) {
r.get('/ping', (req, res) => res.json({ ok: true, service: 'zone' }));
++ r.get('/share-defaults/zone', principal.requireCapability('zone.zone.read'), wrap(async (req, res) => {
++ if (req.principal.kind !== 'user') return send(req, res, 403, 'share.person_required', 'A person owns this setting');
++ res.json({ audience: await ctx.sharing.defaultFor(req.principal.requester.slice(5)), audiences: ['people', 'private'] });
++ }));
++
++ r.put('/share-defaults/zone', principal.requireCapability('zone.zone.manage'), limits.budget('zone.write'), json, wrap(async (req, res) => {
++ if (req.principal.kind !== 'user') return send(req, res, 403, 'share.person_required', 'A person owns this setting');
++ const body = req.body || {};
++ if (body.apply_to_all !== undefined && typeof body.apply_to_all !== 'boolean') {
++ return send(req, res, 400, 'share.invalid', 'apply_to_all must be a boolean');
++ }
++ res.json(await ctx.sharing.saveDefault(req.principal.requester.slice(5), body.audience, { apply: body.apply_to_all === true }));
++ }));
++
r.post('/projects/:project/zones', principal.requireCapability('zone.zone.create'), limits.budget('zone.write'), json, wrap(async (req, res) => {
const body = req.body || {};
const caller = await access(req, req.params.project, { write: true, env: body.env });
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2f136b3..d09d724 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -31,6 +31,7 @@ const { pickBlock, foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { ZoneError, RESERVED } = require('../zone/zones');
const { ClientError } = require('../zone/clients');
++const { ShareError } = require('openvibe-sdk/share');
const SITE_NAME = 'OpenVibe.Zone';
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -50,7 +51,7 @@ const plural = (n, one, many) => `${Number(n).toLocaleString('en-US')} ${n === 1
const day = (iso) => (iso ? String(iso).slice(0, 10) : '—');
function createPageRoutes(ctx) {
-- const { config, clients, zones, usage: metering } = ctx;
++ const { config, clients, zones, sharing, usage: metering } = ctx;
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '8kb' });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
@@@ -217,6 +218,7 @@ ${projects.length ? html`<ul class="zn-cards">${projects.map((p) => html`<li cla
const p = projects.find((x) => x.id === project);
if (!p || !role) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such project of yours. <a href="/projects">Your projects</a>.</p>` }, 404);
const admin = ADMIN.has(role);
++ const defaultAudience = admin ? await sharing.defaultFor(me.subject) : 'private';
const envs = p.environments && p.environments.length ? p.environments : ['sandbox'];
for (const env of envs) await zones.ensureDefault(project, env);
const list = (await zones.list(project, {})).zones;
@@@ -282,6 +284,12 @@ ${error ? html`<p class="zn-error" role="alert">${error}</p>` : ''}
${secret ? html`<div class="zn-secret" role="alert"><h2>Your new key</h2><p>Copy the secret now: it is shown only once.</p>
<dl><dt>Access key id</dt><dd><code>${secret.key.id}</code></dd><dt>Secret access key</dt><dd><code>${secret.secret}</code></dd><dt>Endpoint</dt><dd><code>${s3}</code> (path style, region <code>us-east-1</code>)</dd></dl></div>` : ''}
${envs.map(envSection)}
++${admin ? html`<section class="zn-section" aria-labelledby="zn-share-default"><h2 id="zn-share-default">New bucket sharing</h2>
++<p>This controls who can open the bucket's console record. Storage access still uses your project's S3 keys. Specific people can be named on each bucket's Share sheet.</p>
++<form class="zn-form" method="post" action="/p/${project}/share-default">
++<label>New buckets <select name="audience"><option value="private"${defaultAudience === 'private' ? raw(' selected') : ''}>Only me</option><option value="people"${defaultAudience === 'people' ? raw(' selected') : ''}>Specific people</option></select></label>
++<label class="zn-check"><input type="checkbox" name="apply_to_all" value="1"> Apply to all my existing named buckets</label>
++<button class="zn-button" type="submit">Save sharing preference</button></form></section>` : ''}
${keysSection}
<p class="zn-muted">Endpoint: <code>${s3}</code>. <a href="/docs">How to use it</a>.</p>`,
cache: null,
@@@ -303,7 +311,7 @@ ${keysSection}
const out = await fn(req, me, project);
return await projectView(req, res, project, out);
} catch (err) {
-- if (err instanceof ZoneError || err instanceof ClientError) return await projectView(req, res, project, { error: err.detail || err.message });
++ if (err instanceof ZoneError || err instanceof ClientError || err instanceof ShareError) return await projectView(req, res, project, { error: err.detail || err.message });
throw err;
}
}];
@@@ -319,6 +327,12 @@ ${keysSection}
return { notice: `Zone ${out.zone.name} is ${out.zone.state === 'active' ? 'ready' : 'being set up'} in ${out.zone.env}.` };
}));
++ r.post('/p/:project/share-default', ...action(async (req, me) => {
++ const b = req.body || {};
++ const out = await sharing.saveDefault(me.subject, b.audience, { apply: b.apply_to_all === '1' });
++ return { notice: `New buckets: ${out.audience === 'private' ? 'Only me' : 'Specific people'}.${b.apply_to_all === '1' ? ` Updated ${out.applied} existing named buckets.` : ''}` };
++ }));
++
r.post('/p/:project/zones/:zone/delete', ...action(async (req, me, project) => {
if (!ZONE_RE.test(req.params.zone)) throw new ZoneError(404, 'zone.unknown', 'no such zone');
const row = await zones.byId(req.params.zone);
ddiff --git a/server/sharing.js b/server/sharing.js
iindex 4a8ff38..3ab9736 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -132,6 +132,33 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
const sv = (viewer) => shareViewer(viewer);
const at = (id) => refOf(id);
++ async function defaultFor(subject) {
++ const row = await db.maybe('SELECT audience FROM zone_share_defaults WHERE subject = $1', [subject]);
++ return row ? row.audience : 'private';
++ }
++
++ async function saveDefault(subject, audience, { apply = false } = {}) {
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || '')) || !AUDIENCES.includes(audience)) {
++ throw new ShareError(400, 'share.invalid', 'Choose Specific people or Only me');
++ }
++ await db.exec(`INSERT INTO zone_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())
++ ON CONFLICT (subject) DO UPDATE SET audience = EXCLUDED.audience, updated_at = EXCLUDED.updated_at`, [subject, audience]);
++ let applied = 0;
++ if (apply) {
++ // A bucket belongs to a project, but its Share sheet belongs to its maker (or transferee).
++ // Never change the project's default buckets or a teammate's named buckets.
++ const rows = await db.many(`SELECT z.id FROM zone_zones z JOIN shares s
++ ON s.service = $1 AND s.kind = $2 AND s.resource_id = z.id
++ WHERE s.owner_subject->>'type' = 'user' AND s.owner_subject->>'id' = $3
++ AND NOT z.is_default AND z.state NOT IN ('deleting', 'deleted') ORDER BY z.id`, [SERVICE, KIND, subject]);
++ for (const row of rows) {
++ await shares.setAudience(sv({ subject }), at(row.id), audience);
++ applied++;
++ }
++ }
++ return { audience, applied };
++ }
++
return {
shares,
SERVICE,
@@@ -139,6 +166,8 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
AUDIENCES,
refOf,
shareViewer,
++ defaultFor,
++ saveDefault,
/** Register a post with the sharing system (inside the post's own transaction). */
register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },
ddiff --git a/server/zone/zones.js b/server/zone/zones.js
iindex ef6b2a6..7e90031 100644
---- a/server/zone/zones.js
++++ b/server/zone/zones.js
@@@ -126,11 +126,11 @@ function createZones({ s, clients, config, log = console, metering = null, shari
await db.exec('INSERT INTO zone_idempotency (caller, idempotency_key, digest, zone_id, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING',
[caller, idempotencyKey, digest, row.id, s.now()]);
}
-- // A new bucket is Only me. Its row records the person who made it, so their Share sheet owns it.
++ // The maker's console preference applies only to named buckets. S3 access stays project-owned.
if (sharing) {
const maker = /^user:usr_/.test(String(caller)) ? caller.slice(5) : null;
await db.exec('UPDATE zone_zones SET created_by = $2 WHERE id = $1', [row.id, maker]);
-- await sharing.register(maker ? { subject: maker } : null, row.id, 'private');
++ await sharing.register(maker ? { subject: maker } : null, row.id, maker ? await sharing.defaultFor(maker) : 'private');
}
return { zone: record(row), repeat: false };
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 5845295..1f29534 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -29,11 +29,13 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'ne
share_members: 'openvibe-sdk/share: its subject is jsonb, so the SDK table map cannot name it; extraExport/extraErase cover it',
share_access_requests: 'openvibe-sdk/share: jsonb subject, covered by extraExport/extraErase',
zone_zones: 'a bucket belongs to its project; its maker is in created_by, which extraErase clears (the bucket stays)',
++ zone_share_defaults: 'personal sharing preference, covered by extraExport/extraErase',
} });
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
});
await check('an export carries the person\'s telemetry samples, and nobody else\'s', async () => {
++ await db.exec("INSERT INTO zone_share_defaults (subject, audience) VALUES ($1, 'people'), ($2, 'private')", [A, B]);
for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
await db.exec("INSERT INTO telemetry_samples (at, service, operation, resource, status, latency_ms, subject) VALUES (now(), 'zone', 'http.request', '/zones', '2xx', 12, $1)", [subject]);
}
@@@ -48,6 +50,8 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'ne
assert.ok(samples, 'telemetry.json is in the part');
assert.strictEqual(samples.content.length, 1, JSON.stringify(samples.content));
assert.strictEqual(samples.content[0].resource, '/zones');
++ const defaults = part.body.files.find((f) => f.name === 'share-defaults.json');
++ assert.deepStrictEqual(defaults.content.map((row) => row.subject), [A]);
assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');
});
@@@ -58,6 +62,8 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'ne
assert.strictEqual(res.json().outcome, 'erased');
assert.strictEqual(await db.value('SELECT subject FROM telemetry_samples WHERE subject = $1', [A]), null, 'her samples keep no subject');
assert.strictEqual(await db.value('SELECT subject FROM telemetry_samples WHERE subject = $1', [B]), B, 'Bo stays');
++ assert.strictEqual(await db.value('SELECT subject FROM zone_share_defaults WHERE subject = $1', [A]), null);
++ assert.strictEqual(await db.value('SELECT subject FROM zone_share_defaults WHERE subject = $1', [B]), B);
const confirmations = t.network.state.accountConfirmations;
const confirmation = confirmations.find((c) => c.id === DEL);
assert.ok(confirmation, 'the confirmation was sent');
ddiff --git a/test/zone-share-defaults.test.js b/test/zone-share-defaults.test.js
nnew file mode 100644
iindex 0000000..e6129e3
---- /dev/null
++++ b/test/zone-share-defaults.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ let t;
++ const media = {
++ async provision() { return { applied: true }; },
++ async report() { return null; },
++ async memberRole(project, subject) {
++ const members = t.network.state.members.get(project);
++ const p = t.network.state.projects.get(project);
++ return members && p && !p.archived_at ? members.get(subject) || null : null;
++ },
++ async listKeys() { return []; },
++ async projectsOf(token) {
++ const res = await fetch(`${t.network.url}/api/v1/projects`, { headers: { Authorization: `Bearer ${token}` } });
++ return (await res.json()).projects;
++ },
++ };
++ t = await boot({ clients: media });
++ const owner = t.network.addUser('owner');
++ const other = t.network.addUser('other');
++ const stranger = t.network.addUser('stranger');
++ const made = await fetch(`${t.network.url}/api/v1/projects`, {
++ method: 'POST', headers: { Authorization: `Bearer ${t.network.userToken(owner)}`, 'Content-Type': 'application/json' },
++ body: JSON.stringify({ name: 'Sharing defaults' }),
++ });
++ const project = (await made.json()).id;
++ t.network.state.members.get(project).set(other.subject, 'admin');
++ t.network.setEnvironmentPolicy(project, 'sandbox+production');
++ const same = { 'sec-fetch-site': 'same-origin' };
++ const share = async (id) => await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['zone', 'zone', id]);
++ const create = async (as, name) => {
++ const r = await t.get(`/api/v1/projects/${project}/zones`, { as, json: { env: 'production', name }, headers: same });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json();
++ };
++
++ await check('preference is private, personal, and a session write requires same origin', async () => {
++ const get = await t.get('/api/v1/share-defaults/zone', { as: owner });
++ assert.strictEqual(get.status, 200, get.text);
++ assert.strictEqual(get.json().audience, 'private');
++ assert.strictEqual((await t.get('/api/v1/share-defaults/zone', { as: stranger })).json().audience, 'private');
++ assert.strictEqual((await t.get('/api/v1/share-defaults/zone')).status, 401);
++ const cross = await t.get('/api/v1/share-defaults/zone', { as: owner, method: 'PUT', json: { audience: 'people' } });
++ assert.strictEqual(cross.status, 403);
++ const bad = await t.get('/api/v1/share-defaults/zone', { as: owner, method: 'PUT', json: { audience: 'public' }, headers: same });
++ assert.strictEqual(bad.status, 400);
++ });
++
++ let own, teammate;
++ await check('new named buckets use the maker preference; project defaults stay private', async () => {
++ const set = await t.get('/api/v1/share-defaults/zone', { as: owner, method: 'PUT', json: { audience: 'people' }, headers: same });
++ assert.strictEqual(set.status, 200, set.text);
++ own = await create(owner, 'mine');
++ teammate = await create(other, 'theirs');
++ assert.strictEqual((await share(own.id)).audience, 'people');
++ assert.strictEqual((await share(teammate.id)).audience, 'private');
++ assert.strictEqual((await t.get(`/api/v1/zones/${own.id}`, { as: stranger })).status, 404,
++ 'Specific people without recipients does not open the bucket');
++ const defaultBucket = await t.ctx.s.db.maybe("SELECT * FROM zone_zones WHERE project_id = $1 AND name = 'default'", [project]);
++ assert.strictEqual(await share(defaultBucket.id), null);
++ });
++
++ await check('per-bucket override and apply to all touch only the maker-owned named buckets', async () => {
++ const override = await t.get(`/api/v1/zone/${own.id}/share`, {
++ as: owner, method: 'PUT', json: { audience: 'private' }, headers: { ...same, 'x-requested-with': 'ov-share' },
++ });
++ assert.strictEqual(override.status, 200, override.text);
++ assert.strictEqual((await share(own.id)).audience, 'private');
++ const own2 = await create(owner, 'mine-two');
++ const set = await t.get('/api/v1/share-defaults/zone', {
++ as: owner, method: 'PUT', json: { audience: 'private', apply_to_all: true }, headers: same,
++ });
++ assert.strictEqual(set.status, 200, set.text);
++ assert.strictEqual(set.json().applied, 2);
++ assert.strictEqual((await share(own2.id)).audience, 'private');
++ assert.strictEqual((await share(teammate.id)).audience, 'private');
++ const row = await t.ctx.s.db.maybe('SELECT audience FROM zone_share_defaults WHERE subject = $1', [owner.subject]);
++ assert.strictEqual(row.audience, 'private');
++ });
++
++ await check('project page exposes and saves the personal choice', async () => {
++ const page = await t.get(`/p/${project}`, { as: owner });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
++ assert(page.text.includes('Apply to all my existing named buckets'));
++ const post = await t.get(`/p/${project}/share-default`, {
++ as: owner, method: 'POST', form: { audience: 'people', apply_to_all: '1' }, headers: same,
++ });
++ assert.strictEqual(post.status, 200, post.text.slice(0, 300));
++ assert(post.text.includes('Updated 2 existing named buckets'));
++ assert.strictEqual((await share(own.id)).audience, 'people');
++ assert.strictEqual((await share(teammate.id)).audience, 'private');
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });