openvibers/openvibe.zone Commits
Add personal sharing defaults for Zone buckets (#209)
Changes
nnew file mode 100644@@@ -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()++);@@@ -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]);@@@ -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 });@@@ -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 === 1const 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 claconst 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);@@@ -132,6 +132,33 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetcconst 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.fetcAUDIENCES,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 },@@@ -126,11 +126,11 @@ function createZones({ s, clients, config, log = console, metering = null, shariawait 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 };}@@@ -29,11 +29,13 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'neshare_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: 'neassert.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: 'neassert.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');nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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()
+);
@@ -19,14 +19,17 @@ const TABLES = [];
@@ -19,14 +19,17 @@ const TABLES = [];
* column, so they are exported and erased here, and the guard knows them by reason.
* column, so they are exported and erased here, and the guard knows them by reason.
*/
*/
async function extraExport(db, subject) {
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 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 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]);
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) {
async function extraErase(t, subjects) {
for (const subject of 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_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_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]);
await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['zone', subject]);
@@ -24,6 +24,7 @@ const contracts = require('openvibe-contracts');
@@ -24,6 +24,7 @@ const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { ZoneError } = require('../zone/zones');
const { ZoneError } = require('../zone/zones');
const { ClientError } = require('../zone/clients');
const { ClientError } = require('../zone/clients');
+const { ShareError } = require('openvibe-sdk/share');
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;
const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -38,7 +39,7 @@ function createApi(ctx) {
@@ -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 send = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
const wrap = (fn) => async (req, res) => {
const wrap = (fn) => async (req, res) => {
try { await fn(req, res); } catch (err) {
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;
throw err;
}
}
};
};
@@ -83,6 +84,20 @@ function createApi(ctx) {
@@ -83,6 +84,20 @@ function createApi(ctx) {
r.get('/ping', (req, res) => res.json({ ok: true, service: 'zone' }));
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) => {
r.post('/projects/:project/zones', principal.requireCapability('zone.zone.create'), limits.budget('zone.write'), json, wrap(async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const caller = await access(req, req.params.project, { write: true, env: body.env });
const caller = await access(req, req.params.project, { write: true, env: body.env });
@@ -31,6 +31,7 @@ const { pickBlock, foldBlock } = require('../render/short');
@@ -31,6 +31,7 @@ const { pickBlock, foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { ZoneError, RESERVED } = require('../zone/zones');
const { ZoneError, RESERVED } = require('../zone/zones');
const { ClientError } = require('../zone/clients');
const { ClientError } = require('../zone/clients');
+const { ShareError } = require('openvibe-sdk/share');
const SITE_NAME = 'OpenVibe.Zone';
const SITE_NAME = 'OpenVibe.Zone';
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
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
@@ -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) : '—');
const day = (iso) => (iso ? String(iso).slice(0, 10) : '—');
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, clients, zones, usage: metering } = ctx;
+ const { config, clients, zones, sharing, usage: metering } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '8kb' });
const form = express.urlencoded({ extended: false, limit: '8kb' });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
@@ -217,6 +218,7 @@ ${projects.length ? html`<ul class="zn-cards">${projects.map((p) => html`<li cla
@@ -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);
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);
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 admin = ADMIN.has(role);
+ const defaultAudience = admin ? await sharing.defaultFor(me.subject) : 'private';
const envs = p.environments && p.environments.length ? p.environments : ['sandbox'];
const envs = p.environments && p.environments.length ? p.environments : ['sandbox'];
for (const env of envs) await zones.ensureDefault(project, env);
for (const env of envs) await zones.ensureDefault(project, env);
const list = (await zones.list(project, {})).zones;
const list = (await zones.list(project, {})).zones;
@@ -282,6 +284,12 @@ ${error ? html`<p class="zn-error" role="alert">${error}</p>` : ''}
@@ -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>
${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>` : ''}
<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)}
${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}
${keysSection}
<p class="zn-muted">Endpoint: <code>${s3}</code>. <a href="/docs">How to use it</a>.</p>`,
<p class="zn-muted">Endpoint: <code>${s3}</code>. <a href="/docs">How to use it</a>.</p>`,
cache: null,
cache: null,
@@ -303,7 +311,7 @@ ${keysSection}
@@ -303,7 +311,7 @@ ${keysSection}
const out = await fn(req, me, project);
const out = await fn(req, me, project);
return await projectView(req, res, project, out);
return await projectView(req, res, project, out);
} catch (err) {
} 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;
throw err;
}
}
}];
}];
@@ -319,6 +327,12 @@ ${keysSection}
@@ -319,6 +327,12 @@ ${keysSection}
return { notice: `Zone ${out.zone.name} is ${out.zone.state === 'active' ? 'ready' : 'being set up'} in ${out.zone.env}.` };
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) => {
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');
if (!ZONE_RE.test(req.params.zone)) throw new ZoneError(404, 'zone.unknown', 'no such zone');
const row = await zones.byId(req.params.zone);
const row = await zones.byId(req.params.zone);
@@ -132,6 +132,33 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
@@ -132,6 +132,33 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
const sv = (viewer) => shareViewer(viewer);
const sv = (viewer) => shareViewer(viewer);
const at = (id) => refOf(id);
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 {
return {
shares,
shares,
SERVICE,
SERVICE,
@@ -139,6 +166,8 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
@@ -139,6 +166,8 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
AUDIENCES,
AUDIENCES,
refOf,
refOf,
shareViewer,
shareViewer,
+ defaultFor,
+ saveDefault,
/** Register a post with the sharing system (inside the post's own transaction). */
/** Register a post with the sharing system (inside the post's own transaction). */
register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
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 },
owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },
@@ -126,11 +126,11 @@ function createZones({ s, clients, config, log = console, metering = null, shari
@@ -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',
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()]);
[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) {
if (sharing) {
const maker = /^user:usr_/.test(String(caller)) ? caller.slice(5) : null;
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 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 };
return { zone: record(row), repeat: false };
}
}
@@ -29,11 +29,13 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'ne
@@ -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_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',
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_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(', ')}`);
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 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']]) {
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]);
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
@@ -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.ok(samples, 'telemetry.json is in the part');
assert.strictEqual(samples.content.length, 1, JSON.stringify(samples.content));
assert.strictEqual(samples.content.length, 1, JSON.stringify(samples.content));
assert.strictEqual(samples.content[0].resource, '/zones');
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');
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
@@ -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(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', [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 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 confirmations = t.network.state.accountConfirmations;
const confirmation = confirmations.find((c) => c.id === DEL);
const confirmation = confirmations.find((c) => c.id === DEL);
assert.ok(confirmation, 'the confirmation was sent');
assert.ok(confirmation, 'the confirmation was sent');
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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); });