openvibers/openvibe.bot Commits
Bot: a person's worker machines are in their account export and are revoked on deletion (#99)
Changes
@@@ -11,6 +11,8 @@* row stays, revoked, without its name.* elsewhere their operator role and queue place on other people's robots go; their id leaves other* robots' command history (operator_subject), e-stop marks, pairing codes and invitations.++ * their machines the worker machines they added (no robot, plan T14/T16) are exported, then revoked and kept++ * without a name or an owner (run jobs reference them).* kept run jobs they started: usage seconds reported to Billing, counted as retained.** Nothing exported is a secret: device credentials and publish keys are stored as hashes and never exported.@@@ -32,6 +34,9 @@ const TABLES = [// withdrawn go (extraErase) and an approved one stays in the catalogue, authorless (robots may use it).{ table: 'profile_submissions', subject: 'submitter_subject', file: 'robot-submissions.json', columns: ['profile_id', 'profile_version', 'profile', 'driver_url', 'guide_url', 'license', 'notes', 'status', 'review_note', 'created_at'], order: 'created_at', erase: { keep: 'approved profiles stay in the catalogue, authorless (extraErase)' } },{ table: 'profile_submissions', subject: 'reviewed_by', file: null, erase: anonymize },++ // Their own machines that run their jobs (plan T14/T16): exported here; revoked and made nameless and ownerless in++ // extraErase (run jobs reference the device rows and are billing records).++ { table: 'devices', subject: 'owner_subject', file: 'machines.json', columns: ['id', 'name', 'kind', 'agent_version', 'created_at', 'last_seen', 'revoked_at'], order: 'created_at', erase: { keep: 'revoked and made ownerless in extraErase' } },];async function extraExport(db, subject) {@@@ -49,6 +54,11 @@ async function extraExport(db, subject) {/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */function create({ domain, log = console } = {}) {async function extraErase(t, subjects, counts) {++ // Their worker machines: revoked (the link closes as the device is next seen), then the row keeps no name or owner.++ const machines = await t.many('SELECT id FROM devices WHERE owner_subject = ANY($1::text[]) AND revoked_at IS NULL', [subjects]);++ for (const d of machines) await domain.devices.revoke(d.id);++ // (The table map's `devices` entry already counts them as retained.)++ await t.exec('UPDATE devices SET owner_subject = NULL, name = NULL WHERE owner_subject = ANY($1::text[])', [subjects]);// A submission that never reached the catalogue is the person's alone; an approved one stays, without its author.counts.add(counts.erased, 'profile_submissions', await t.exec("DELETE FROM profile_submissions WHERE submitter_subject = ANY($1::text[]) AND status <> 'approved'", [subjects]));await t.exec("UPDATE profile_submissions SET submitter_subject = NULL WHERE submitter_subject = ANY($1::text[])", [subjects]);@@@ -58,6 +58,10 @@ async function startNetworkStub() {const add = await t.call('POST', `/api/v1/robots/${theirs.id}/operators`, { user: bob, body: { subject: alex.subject, role: 'operator' } });assert.ok(add.status < 300, add.text);const at = new Date().toISOString();++ // A machine of hers that runs her jobs and serves no robot (plan T14/T16), and one of bob's.++ await db.exec(`INSERT INTO devices (id, robot_ids, name, kind, capabilities, credential_hash, owner_subject, created_at, updated_at) VALUES++ ('dev_01JZ0000000000000000000MA1', '[]'::jsonb, 'Alex desktop', 'server', '{}'::jsonb, NULL, $1, $3, $3),++ ('dev_01JZ0000000000000000000MB1', '[]'::jsonb, 'Bob laptop', 'server', '{}'::jsonb, NULL, $2, $3, $3)`, [alex.subject, bob.subject, at]);await db.exec(`INSERT INTO command_audit (robot_id, operator_subject, operator_kind, role, kind, result, at) VALUES($1, $2, 'user', 'operator', 'drive', 'ack', $4), ($3, $5, 'user', 'operator', 'drive', 'ack', $4)`, [theirs.id, alex.subject, mine.id, at, bob.subject]);});@@@ -69,6 +73,7 @@ async function startNetworkStub() {assert.strictEqual(part.auth, 'Bearer tok_bot');const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));assert.deepStrictEqual(files['robots.json'].map((x) => x.name), ['Alex rover']);++ assert.deepStrictEqual(files['machines.json'].map((x) => x.name), ['Alex desktop']);assert.ok(files['operating.json'].some((o) => o.robot_id === theirs.id));assert.ok(!JSON.stringify(part.body).includes(bob.subject));assert.ok(!/credential|publish_key/i.test(JSON.stringify(part.body)), 'no device secret');@@@ -90,7 +95,13 @@ async function startNetworkStub() {const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');assert.strictEqual(conf.length, 1);// robot_operators: her owner row on her own robot and her operator role on bob's.-- assert.deepStrictEqual([conf[0].body.erased.robots, conf[0].body.erased.robot_operators, conf[0].body.retained.devices], [1, 2, 1]);++ // retained devices: the one that served her robot, and her worker machine.++ assert.deepStrictEqual([conf[0].body.erased.robots, conf[0].body.erased.robot_operators, conf[0].body.retained.devices], [1, 2, 2]);++ const machine = await db.maybe("SELECT revoked_at, name, owner_subject FROM devices WHERE id = 'dev_01JZ0000000000000000000MA1'");++ assert.ok(machine.revoked_at, 'her worker machine is revoked');++ assert.deepStrictEqual([machine.name, machine.owner_subject], [null, null], 'and keeps no name or owner');++ const bobs = await db.maybe("SELECT revoked_at, owner_subject FROM devices WHERE id = 'dev_01JZ0000000000000000000MB1'");++ assert.deepStrictEqual([bobs.revoked_at, bobs.owner_subject], [null, bob.subject], 'bob\'s machine is untouched');assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);});
@@ -11,6 +11,8 @@
@@ -11,6 +11,8 @@
* row stays, revoked, without its name.
* row stays, revoked, without its name.
* elsewhere their operator role and queue place on other people's robots go; their id leaves other
* elsewhere their operator role and queue place on other people's robots go; their id leaves other
* robots' command history (operator_subject), e-stop marks, pairing codes and invitations.
* robots' command history (operator_subject), e-stop marks, pairing codes and invitations.
+ * their machines the worker machines they added (no robot, plan T14/T16) are exported, then revoked and kept
+ * without a name or an owner (run jobs reference them).
* kept run jobs they started: usage seconds reported to Billing, counted as retained.
* kept run jobs they started: usage seconds reported to Billing, counted as retained.
*
*
* Nothing exported is a secret: device credentials and publish keys are stored as hashes and never exported.
* Nothing exported is a secret: device credentials and publish keys are stored as hashes and never exported.
@@ -32,6 +34,9 @@ const TABLES = [
@@ -32,6 +34,9 @@ const TABLES = [
// withdrawn go (extraErase) and an approved one stays in the catalogue, authorless (robots may use it).
// withdrawn go (extraErase) and an approved one stays in the catalogue, authorless (robots may use it).
{ table: 'profile_submissions', subject: 'submitter_subject', file: 'robot-submissions.json', columns: ['profile_id', 'profile_version', 'profile', 'driver_url', 'guide_url', 'license', 'notes', 'status', 'review_note', 'created_at'], order: 'created_at', erase: { keep: 'approved profiles stay in the catalogue, authorless (extraErase)' } },
{ table: 'profile_submissions', subject: 'submitter_subject', file: 'robot-submissions.json', columns: ['profile_id', 'profile_version', 'profile', 'driver_url', 'guide_url', 'license', 'notes', 'status', 'review_note', 'created_at'], order: 'created_at', erase: { keep: 'approved profiles stay in the catalogue, authorless (extraErase)' } },
{ table: 'profile_submissions', subject: 'reviewed_by', file: null, erase: anonymize },
{ table: 'profile_submissions', subject: 'reviewed_by', file: null, erase: anonymize },
+ // Their own machines that run their jobs (plan T14/T16): exported here; revoked and made nameless and ownerless in
+ // extraErase (run jobs reference the device rows and are billing records).
+ { table: 'devices', subject: 'owner_subject', file: 'machines.json', columns: ['id', 'name', 'kind', 'agent_version', 'created_at', 'last_seen', 'revoked_at'], order: 'created_at', erase: { keep: 'revoked and made ownerless in extraErase' } },
];
];
async function extraExport(db, subject) {
async function extraExport(db, subject) {
@@ -49,6 +54,11 @@ async function extraExport(db, subject) {
@@ -49,6 +54,11 @@ async function extraExport(db, subject) {
/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */
/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */
function create({ domain, log = console } = {}) {
function create({ domain, log = console } = {}) {
async function extraErase(t, subjects, counts) {
async function extraErase(t, subjects, counts) {
+ // Their worker machines: revoked (the link closes as the device is next seen), then the row keeps no name or owner.
+ const machines = await t.many('SELECT id FROM devices WHERE owner_subject = ANY($1::text[]) AND revoked_at IS NULL', [subjects]);
+ for (const d of machines) await domain.devices.revoke(d.id);
+ // (The table map's `devices` entry already counts them as retained.)
+ await t.exec('UPDATE devices SET owner_subject = NULL, name = NULL WHERE owner_subject = ANY($1::text[])', [subjects]);
// A submission that never reached the catalogue is the person's alone; an approved one stays, without its author.
// A submission that never reached the catalogue is the person's alone; an approved one stays, without its author.
counts.add(counts.erased, 'profile_submissions', await t.exec("DELETE FROM profile_submissions WHERE submitter_subject = ANY($1::text[]) AND status <> 'approved'", [subjects]));
counts.add(counts.erased, 'profile_submissions', await t.exec("DELETE FROM profile_submissions WHERE submitter_subject = ANY($1::text[]) AND status <> 'approved'", [subjects]));
await t.exec("UPDATE profile_submissions SET submitter_subject = NULL WHERE submitter_subject = ANY($1::text[])", [subjects]);
await t.exec("UPDATE profile_submissions SET submitter_subject = NULL WHERE submitter_subject = ANY($1::text[])", [subjects]);
@@ -58,6 +58,10 @@ async function startNetworkStub() {
@@ -58,6 +58,10 @@ async function startNetworkStub() {
const add = await t.call('POST', `/api/v1/robots/${theirs.id}/operators`, { user: bob, body: { subject: alex.subject, role: 'operator' } });
const add = await t.call('POST', `/api/v1/robots/${theirs.id}/operators`, { user: bob, body: { subject: alex.subject, role: 'operator' } });
assert.ok(add.status < 300, add.text);
assert.ok(add.status < 300, add.text);
const at = new Date().toISOString();
const at = new Date().toISOString();
+ // A machine of hers that runs her jobs and serves no robot (plan T14/T16), and one of bob's.
+ await db.exec(`INSERT INTO devices (id, robot_ids, name, kind, capabilities, credential_hash, owner_subject, created_at, updated_at) VALUES
+ ('dev_01JZ0000000000000000000MA1', '[]'::jsonb, 'Alex desktop', 'server', '{}'::jsonb, NULL, $1, $3, $3),
+ ('dev_01JZ0000000000000000000MB1', '[]'::jsonb, 'Bob laptop', 'server', '{}'::jsonb, NULL, $2, $3, $3)`, [alex.subject, bob.subject, at]);
await db.exec(`INSERT INTO command_audit (robot_id, operator_subject, operator_kind, role, kind, result, at) VALUES
await db.exec(`INSERT INTO command_audit (robot_id, operator_subject, operator_kind, role, kind, result, at) VALUES
($1, $2, 'user', 'operator', 'drive', 'ack', $4), ($3, $5, 'user', 'operator', 'drive', 'ack', $4)`, [theirs.id, alex.subject, mine.id, at, bob.subject]);
($1, $2, 'user', 'operator', 'drive', 'ack', $4), ($3, $5, 'user', 'operator', 'drive', 'ack', $4)`, [theirs.id, alex.subject, mine.id, at, bob.subject]);
});
});
@@ -69,6 +73,7 @@ async function startNetworkStub() {
@@ -69,6 +73,7 @@ async function startNetworkStub() {
assert.strictEqual(part.auth, 'Bearer tok_bot');
assert.strictEqual(part.auth, 'Bearer tok_bot');
const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
assert.deepStrictEqual(files['robots.json'].map((x) => x.name), ['Alex rover']);
assert.deepStrictEqual(files['robots.json'].map((x) => x.name), ['Alex rover']);
+ assert.deepStrictEqual(files['machines.json'].map((x) => x.name), ['Alex desktop']);
assert.ok(files['operating.json'].some((o) => o.robot_id === theirs.id));
assert.ok(files['operating.json'].some((o) => o.robot_id === theirs.id));
assert.ok(!JSON.stringify(part.body).includes(bob.subject));
assert.ok(!JSON.stringify(part.body).includes(bob.subject));
assert.ok(!/credential|publish_key/i.test(JSON.stringify(part.body)), 'no device secret');
assert.ok(!/credential|publish_key/i.test(JSON.stringify(part.body)), 'no device secret');
@@ -90,7 +95,13 @@ async function startNetworkStub() {
@@ -90,7 +95,13 @@ async function startNetworkStub() {
const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
assert.strictEqual(conf.length, 1);
assert.strictEqual(conf.length, 1);
// robot_operators: her owner row on her own robot and her operator role on bob's.
// robot_operators: her owner row on her own robot and her operator role on bob's.
- assert.deepStrictEqual([conf[0].body.erased.robots, conf[0].body.erased.robot_operators, conf[0].body.retained.devices], [1, 2, 1]);
+ // retained devices: the one that served her robot, and her worker machine.
+ assert.deepStrictEqual([conf[0].body.erased.robots, conf[0].body.erased.robot_operators, conf[0].body.retained.devices], [1, 2, 2]);
+ const machine = await db.maybe("SELECT revoked_at, name, owner_subject FROM devices WHERE id = 'dev_01JZ0000000000000000000MA1'");
+ assert.ok(machine.revoked_at, 'her worker machine is revoked');
+ assert.deepStrictEqual([machine.name, machine.owner_subject], [null, null], 'and keeps no name or owner');
+ const bobs = await db.maybe("SELECT revoked_at, owner_subject FROM devices WHERE id = 'dev_01JZ0000000000000000000MB1'");
+ assert.deepStrictEqual([bobs.revoked_at, bobs.owner_subject], [null, bob.subject], 'bob\'s machine is untouched');
assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
});
});