From 1777d8e37731dce13af45e34a0ea07e2933739b1 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Mon, 28 Aug 2023 15:00:26 +0100 Subject: [PATCH] Add role and capabiltiies --- ...nt_medusa.sql => 0000_volatile_jackal.sql} | 16 ++ drizzle/project/meta/0000_snapshot.json | 90 +++++++- drizzle/project/meta/_journal.json | 4 +- src/capabilities.js | 207 ++++++++++++++++++ src/core-ownership.js | 2 +- src/datastore/index.js | 2 +- src/mapeo-project.js | 45 +++- src/schema/project.js | 2 + test-e2e/capabilities.js | 68 ++++++ 9 files changed, 428 insertions(+), 8 deletions(-) rename drizzle/project/{0000_absent_medusa.sql => 0000_volatile_jackal.sql} (83%) create mode 100644 src/capabilities.js create mode 100644 test-e2e/capabilities.js diff --git a/drizzle/project/0000_absent_medusa.sql b/drizzle/project/0000_volatile_jackal.sql similarity index 83% rename from drizzle/project/0000_absent_medusa.sql rename to drizzle/project/0000_volatile_jackal.sql index 3747739bf..60f7f78c2 100644 --- a/drizzle/project/0000_absent_medusa.sql +++ b/drizzle/project/0000_volatile_jackal.sql @@ -81,3 +81,19 @@ CREATE TABLE `preset` ( `terms` text NOT NULL, `forks` text NOT NULL ); +--> statement-breakpoint +CREATE TABLE `role_backlink` ( + `versionId` text PRIMARY KEY NOT NULL +); +--> statement-breakpoint +CREATE TABLE `role` ( + `docId` text PRIMARY KEY NOT NULL, + `versionId` text NOT NULL, + `schemaName` text NOT NULL, + `createdAt` text NOT NULL, + `updatedAt` text NOT NULL, + `links` text NOT NULL, + `roleId` text NOT NULL, + `fromIndex` real NOT NULL, + `forks` text NOT NULL +); diff --git a/drizzle/project/meta/0000_snapshot.json b/drizzle/project/meta/0000_snapshot.json index 8dbef9055..517b9841e 100644 --- a/drizzle/project/meta/0000_snapshot.json +++ b/drizzle/project/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "sqlite", - "id": "4f7d6d28-3e55-446e-a357-ba9ab3351fad", + "id": "a4376411-f126-4579-adc2-da4f781d00bb", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "coreOwnership_backlink": { @@ -495,6 +495,94 @@ "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {} + }, + "role_backlink": { + "name": "role_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "role": { + "name": "role", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roleId": { + "name": "roleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromIndex": { + "name": "fromIndex", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} } }, "enums": {}, diff --git a/drizzle/project/meta/_journal.json b/drizzle/project/meta/_journal.json index ba6b9ad4c..6565f25aa 100644 --- a/drizzle/project/meta/_journal.json +++ b/drizzle/project/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1693141552742, - "tag": "0000_absent_medusa", + "when": 1693481966662, + "tag": "0000_volatile_jackal", "breakpoints": true } ] diff --git a/src/capabilities.js b/src/capabilities.js new file mode 100644 index 000000000..f62680bac --- /dev/null +++ b/src/capabilities.js @@ -0,0 +1,207 @@ +import { currentSchemaVersions } from '@mapeo/schema' +import mapObject from 'map-obj' +import { kCreateWithDocId } from './datatype/index.js' + +// Randomly generated 8-byte encoded as hex +export const COORDINATOR_ROLE_ID = 'f7c150f5a3a9a855' +export const MEMBER_ROLE_ID = '012fd2d431c0bf60' +export const BLOCKED_ROLE_ID = '9e6d29263cba36c9' + +/** + * @typedef {object} DocCapability + * @property {boolean} readOwn - can read own data + * @property {boolean} writeOwn - can write own data + * @property {boolean} readOthers - can read other's data + * @property {boolean} writeOthers - can edit or delete other's data + */ + +/** + * @typedef {object} Capability + * @property {string} name + * @property {Record} docs + * @property {RoleId[]} roleAssignment + * @property {'allowed' | 'blocked'} sync + */ + +/** + * @typedef {typeof COORDINATOR_ROLE_ID | typeof MEMBER_ROLE_ID | typeof BLOCKED_ROLE_ID} RoleId + */ + +/** + * This is currently the same as 'Coordinator' capabilities, but defined + * separately because the creator should always have ALL capabilities, but we + * could edit 'Coordinator' capabilities in the future + * + * @type {Capability} + */ +export const CREATOR_CAPABILITIES = { + name: 'Project Creator', + docs: mapObject(currentSchemaVersions, (key) => { + return [ + key, + { readOwn: true, writeOwn: true, readOthers: true, writeOthers: true }, + ] + }), + roleAssignment: [COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, BLOCKED_ROLE_ID], + sync: 'allowed', +} + +/** @type {Record} */ +export const DEFAULT_CAPABILITIES = { + [MEMBER_ROLE_ID]: { + name: 'Member', + docs: mapObject(currentSchemaVersions, (key) => { + return [ + key, + { readOwn: true, writeOwn: true, readOthers: true, writeOthers: false }, + ] + }), + roleAssignment: [], + sync: 'allowed', + }, + [COORDINATOR_ROLE_ID]: { + name: 'Coordinator', + docs: mapObject(currentSchemaVersions, (key) => { + return [ + key, + { readOwn: true, writeOwn: true, readOthers: true, writeOthers: true }, + ] + }), + roleAssignment: [COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, BLOCKED_ROLE_ID], + sync: 'allowed', + }, + [BLOCKED_ROLE_ID]: { + name: 'Blocked', + docs: mapObject(currentSchemaVersions, (key) => { + return [ + key, + { + readOwn: false, + writeOwn: false, + readOthers: false, + writeOthers: false, + }, + ] + }), + roleAssignment: [], + sync: 'blocked', + }, +} + +export class Capabilities { + #dataType + #coreOwnership + #coreManager + #projectCreatorAuthCoreId + #ownDeviceId + + /** + * + * @param {object} opts + * @param {import('./datatype/index.js').DataType< + * import('./datastore/index.js').DataStore<'auth'>, + * typeof import('./schema/project.js').roleTable, + * 'role', + * import('@mapeo/schema').Role, + * import('@mapeo/schema').RoleValue + * >} opts.dataType + * @param {import('./core-ownership.js').CoreOwnership} opts.coreOwnership + * @param {import('./core-manager/index.js').CoreManager} opts.coreManager + * @param {Buffer} opts.projectKey + * @param {Buffer} opts.deviceKey public key of this device + */ + constructor({ dataType, coreOwnership, coreManager, projectKey, deviceKey }) { + this.#dataType = dataType + this.#coreOwnership = coreOwnership + this.#coreManager = coreManager + this.#projectCreatorAuthCoreId = projectKey.toString('hex') + this.#ownDeviceId = deviceKey.toString('hex') + } + + /** + * Get the capabilities for device `deviceId`. + * + * @param {string} deviceId + * @returns {Promise} + */ + async getCapabilities(deviceId) { + let roleId + try { + const roleAssignment = await this.#dataType.getByDocId(deviceId) + roleId = roleAssignment.roleId + } catch (e) { + // The project creator will have all capabilities + const authCoreId = await this.#coreOwnership.getCoreId(deviceId, 'auth') + if (authCoreId === this.#projectCreatorAuthCoreId) { + return CREATOR_CAPABILITIES + } else { + return DEFAULT_CAPABILITIES[BLOCKED_ROLE_ID] + } + } + if (!isKnownRoleId(roleId)) { + return DEFAULT_CAPABILITIES[BLOCKED_ROLE_ID] + } + const capabilities = DEFAULT_CAPABILITIES[roleId] + return capabilities + } + + /** + * Assign a role to the specified `deviceId`. Devices without an assigned role + * are unable to sync, except the project creator that defaults to having all + * capabilities. Only the project creator can assign their own role. Will + * throw if the device trying to assign the role lacks the `roleAssignment` + * capability for the given roleId + * + * @param {string} deviceId + * @param {keyof DEFAULT_CAPABILITIES} roleId + */ + async assignRole(deviceId, roleId) { + let fromIndex = 0 + let authCoreId + try { + authCoreId = await this.#coreOwnership.getCoreId(deviceId, 'auth') + const authCoreKey = Buffer.from(authCoreId, 'hex') + const authCore = this.#coreManager.getCoreByKey(authCoreKey) + if (authCore) { + await authCore.ready() + fromIndex = authCore.length + } + } catch { + // This will usually happen when assigning a role to a newly invited + // device that has not yet synced (so we do not yet have a replica of + // their authCore). In this case we want fromIndex to be 0 + } + const isAssigningProjectCreatorRole = + authCoreId === this.#projectCreatorAuthCoreId + if (isAssigningProjectCreatorRole && !this.#isProjectCreator()) { + throw new Error( + "Only the project creator can assign the project creator's role" + ) + } + const ownCapabilities = await this.getCapabilities(this.#ownDeviceId) + if (!ownCapabilities.roleAssignment.includes(roleId)) { + throw new Error('No capability to assign role ' + roleId) + } + await this.#dataType[kCreateWithDocId](deviceId, { + schemaName: 'role', + roleId, + fromIndex, + }) + } + + async #isProjectCreator() { + const ownAuthCoreId = this.#coreManager + .getWriterCore('auth') + .key.toString('hex') + return ownAuthCoreId === this.#projectCreatorAuthCoreId + } +} + +/** + * + * @param {string} roleId + * @returns {roleId is keyof DEFAULT_CAPABILITIES} + */ +function isKnownRoleId(roleId) { + return roleId in DEFAULT_CAPABILITIES +} diff --git a/src/core-ownership.js b/src/core-ownership.js index cd5505610..aae0b6ace 100644 --- a/src/core-ownership.js +++ b/src/core-ownership.js @@ -81,7 +81,7 @@ export class CoreOwnership { }), } const docId = identityKeypair.publicKey.toString('hex') - this.#dataType[kCreateWithDocId](docId, docValue) + await this.#dataType[kCreateWithDocId](docId, docValue) } } diff --git a/src/datastore/index.js b/src/datastore/index.js index cf52bddc5..9ade58d31 100644 --- a/src/datastore/index.js +++ b/src/datastore/index.js @@ -26,7 +26,7 @@ import pDefer from 'p-defer' const NAMESPACE_SCHEMAS = /** @type {const} */ ({ data: ['observation'], config: ['preset', 'field', 'project'], - auth: ['coreOwnership'], + auth: ['coreOwnership', 'role'], }) /** diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 73ecb1f63..96517b5ce 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -4,6 +4,7 @@ import Database from 'better-sqlite3' import { decodeBlockPrefix } from '@mapeo/schema' import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import pDefer from 'p-defer' import { CoreManager, NAMESPACES } from './core-manager/index.js' import { DataStore } from './datastore/index.js' @@ -15,12 +16,14 @@ import { fieldTable, observationTable, presetTable, + roleTable, } from './schema/project.js' import { CoreOwnership, getWinner, mapAndValidateCoreOwnership, } from './core-ownership.js' +import { Capabilities } from './capabilities.js' import { valueOf } from './utils.js' /** @typedef {Omit} EditableProjectSettings */ @@ -28,6 +31,7 @@ import { valueOf } from './utils.js' const CORESTORE_STORAGE_FOLDER_NAME = 'corestore' const INDEXER_STORAGE_FOLDER_NAME = 'indexer' export const kCoreOwnership = Symbol('coreOwnership') +export const kCapabilities = Symbol('capabilities') export class MapeoProject { #coreManager @@ -35,6 +39,8 @@ export class MapeoProject { #dataTypes #projectId #coreOwnership + #capabilities + #ownershipWriteDone /** * @param {Object} opts @@ -87,7 +93,13 @@ export class MapeoProject { sqlite, }) const indexWriter = new IndexWriter({ - tables: [observationTable, presetTable, fieldTable, coreOwnershipTable], + tables: [ + observationTable, + presetTable, + fieldTable, + coreOwnershipTable, + roleTable, + ], sqlite, getWinner, mapDoc: (doc, version) => { @@ -148,13 +160,30 @@ export class MapeoProject { table: coreOwnershipTable, db, }), + role: new DataType({ + dataStore: this.#dataStores.auth, + table: roleTable, + db, + }), } this.#coreOwnership = new CoreOwnership({ dataType: this.#dataTypes.coreOwnership, }) + this.#capabilities = new Capabilities({ + dataType: this.#dataTypes.role, + coreOwnership: this.#coreOwnership, + coreManager: this.#coreManager, + projectKey: projectKey, + deviceKey: keyManager.getIdentityKeypair().publicKey, + }) ///////// 4. Write core ownership record + const deferred = pDefer() + // Avoid uncaught rejection. If this is rejected then project.ready() will reject + deferred.promise.catch(() => {}) + this.#ownershipWriteDone = deferred.promise + const authCore = this.#coreManager.getWriterCore('auth').core authCore.on('ready', () => { if (authCore.length > 0) return @@ -164,7 +193,10 @@ export class MapeoProject { projectSecretKey, keyManager, }) - this.#coreOwnership.writeOwnership(identityKeypair, coreKeypairs) + this.#coreOwnership + .writeOwnership(identityKeypair, coreKeypairs) + .then(deferred.resolve) + .catch(deferred.reject) }) } @@ -175,11 +207,18 @@ export class MapeoProject { return this.#coreOwnership } + /** + * Capabilities instance, used for tests + */ + get [kCapabilities]() { + return this.#capabilities + } + /** * Resolves when hypercores have all loaded */ async ready() { - await this.#coreManager.ready() + await Promise.all([this.#coreManager.ready(), this.#ownershipWriteDone]) } /** diff --git a/src/schema/project.js b/src/schema/project.js index 93d2f6e1f..3da818b10 100644 --- a/src/schema/project.js +++ b/src/schema/project.js @@ -15,8 +15,10 @@ export const coreOwnershipTable = sqliteTable( 'coreOwnership', toColumns(schemas.coreOwnership) ) +export const roleTable = sqliteTable('role', toColumns(schemas.role)) export const observationBacklinkTable = backlinkTable(observationTable) export const presetBacklinkTable = backlinkTable(presetTable) export const fieldBacklinkTable = backlinkTable(fieldTable) export const coreOwnershipBacklinkTable = backlinkTable(coreOwnershipTable) +export const roleBacklinkTable = backlinkTable(roleTable) diff --git a/test-e2e/capabilities.js b/test-e2e/capabilities.js new file mode 100644 index 000000000..acf6d205a --- /dev/null +++ b/test-e2e/capabilities.js @@ -0,0 +1,68 @@ +import { test } from 'brittle' +import { KeyManager } from '@mapeo/crypto' +import { MapeoManager } from '../src/mapeo-manager.js' +import RAM from 'random-access-memory' +import { kCapabilities } from '../src/mapeo-project.js' +import { + DEFAULT_CAPABILITIES, + CREATOR_CAPABILITIES, + MEMBER_ROLE_ID, + BLOCKED_ROLE_ID, +} from '../src/capabilities.js' +import { randomBytes } from 'crypto' + +test('Creator capabilities and role assignment', async (t) => { + const rootKey = KeyManager.generateRootKey() + const km = new KeyManager(rootKey) + const manager = new MapeoManager({ + rootKey, + dbFolder: ':memory:', + coreStorage: () => new RAM(), + }) + + const ownDeviceId = km.getIdentityKeypair().publicKey.toString('hex') + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + const capabilities = project[kCapabilities] + + t.alike( + await capabilities.getCapabilities(ownDeviceId), + CREATOR_CAPABILITIES, + 'Project creator has creator capabilities' + ) + const deviceId = randomBytes(32).toString('hex') + await capabilities.assignRole(deviceId, MEMBER_ROLE_ID) + t.alike( + await capabilities.getCapabilities(deviceId), + DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], + 'Can assign capabilities to device' + ) +}) + +test('New device without capabilities', async (t) => { + const rootKey = KeyManager.generateRootKey() + const km = new KeyManager(rootKey) + const manager = new MapeoManager({ + rootKey, + dbFolder: ':memory:', + coreStorage: () => new RAM(), + }) + const ownDeviceId = km.getIdentityKeypair().publicKey.toString('hex') + console.log('deviceId', ownDeviceId.slice(0, 7)) + const projectId = await manager.addProject({ + projectKey: randomBytes(32), + encryptionKeys: { auth: randomBytes(32) }, + }) + const project = await manager.getProject(projectId) + await project.ready() + const cap = project[kCapabilities] + t.alike( + await cap.getCapabilities(ownDeviceId), + DEFAULT_CAPABILITIES[BLOCKED_ROLE_ID], + 'A new device before sync is blocked' + ) + await t.exception(async () => { + const deviceId = randomBytes(32).toString('hex') + await cap.assignRole(deviceId, MEMBER_ROLE_ID) + }, 'Trying to assign a role without capabilities throws an error') +})