openvibers/openvibe.bot Commits

Bot: a person's worker machines are in their account export and are revoked on deletion (#99)

OP OpenVibers committed 30 days ago · 71f951f403 · 2 files, +22 −1

Follow-up to Bot#98 (ADR-033). A person's worker machines (devices with `owner_subject` and no robot) are personal records too, and the account export and deletion did not cover them.

**Change** (`server/account-data.js`)
- **Export:** `machines.json` holds the person's worker machines (id, name, kind, Node version, created, last seen, revoked).
- **Deletion:**
  - each live worker machine of theirs is revoked, the same way "Remove" does on `/machines`;
  - the row then keeps no name and no owner, because run jobs reference device rows and are billing records;
  - it is counted as retained under `devices`, beside the device that served their robot;
  - someone else's machine is untouched.

**Tests:** `account-data` adds Alex's worker machine and one of Bob's. The export carries Alex's. The deletion revokes hers and clears its name and owner, leaves Bob's alone, and reports 2 retained devices.

Changes

ddiff --git a/server/account-data.js b/server/account-data.js
iindex 9d517b2..f2fb5e9 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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]);
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex ff3617b..61ae791 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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);
});