Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
- need to update leave tests to check data access after leaving (read+write)
- need to update leave tests to confirm that syncing auth still works
  after leaving
- need to add test for Capabilities.assignRole() change
  • Loading branch information
achou11 committed Dec 13, 2023
1 parent 6eac910 commit dd58aea
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 10 deletions.
49 changes: 44 additions & 5 deletions src/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { kCreateWithDocId } from './datatype/index.js'
export const COORDINATOR_ROLE_ID = 'f7c150f5a3a9a855'
export const MEMBER_ROLE_ID = '012fd2d431c0bf60'
export const BLOCKED_ROLE_ID = '9e6d29263cba36c9'
export const LEFT_ROLE_ID = '8ced989b1904606b'

/**
* @typedef {object} DocCapability
Expand All @@ -24,7 +25,7 @@ export const BLOCKED_ROLE_ID = '9e6d29263cba36c9'
*/

/**
* @typedef {typeof COORDINATOR_ROLE_ID | typeof MEMBER_ROLE_ID | typeof BLOCKED_ROLE_ID} RoleId
* @typedef {typeof COORDINATOR_ROLE_ID | typeof MEMBER_ROLE_ID | typeof BLOCKED_ROLE_ID | typeof LEFT_ROLE_ID} RoleId
*/

/**
Expand All @@ -42,7 +43,12 @@ export const CREATOR_CAPABILITIES = {
{ readOwn: true, writeOwn: true, readOthers: true, writeOthers: true },
]
}),
roleAssignment: [COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, BLOCKED_ROLE_ID],
roleAssignment: [
COORDINATOR_ROLE_ID,
MEMBER_ROLE_ID,
BLOCKED_ROLE_ID,
LEFT_ROLE_ID,
],
sync: {
auth: 'allowed',
config: 'allowed',
Expand Down Expand Up @@ -70,7 +76,8 @@ export const NO_ROLE_CAPABILITIES = {
{ readOwn: true, writeOwn: true, readOthers: false, writeOthers: false },
]
}),
roleAssignment: [],
// TODO: Does this make sense?
roleAssignment: [LEFT_ROLE_ID],
sync: {
auth: 'allowed',
config: 'allowed',
Expand All @@ -90,7 +97,7 @@ export const DEFAULT_CAPABILITIES = {
{ readOwn: true, writeOwn: true, readOthers: true, writeOthers: false },
]
}),
roleAssignment: [],
roleAssignment: [LEFT_ROLE_ID],
sync: {
auth: 'allowed',
config: 'allowed',
Expand All @@ -107,7 +114,12 @@ export const DEFAULT_CAPABILITIES = {
{ readOwn: true, writeOwn: true, readOthers: true, writeOthers: true },
]
}),
roleAssignment: [COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, BLOCKED_ROLE_ID],
roleAssignment: [
COORDINATOR_ROLE_ID,
MEMBER_ROLE_ID,
BLOCKED_ROLE_ID,
LEFT_ROLE_ID,
],
sync: {
auth: 'allowed',
config: 'allowed',
Expand Down Expand Up @@ -138,6 +150,28 @@ export const DEFAULT_CAPABILITIES = {
blob: 'blocked',
},
},
[LEFT_ROLE_ID]: {
name: 'Left',
docs: mapObject(currentSchemaVersions, (key) => {
return [
key,
{
readOwn: false,
writeOwn: false,
readOthers: false,
writeOthers: false,
},
]
}),
roleAssignment: [],
sync: {
auth: 'allowed',
config: 'blocked',
data: 'blocked',
blobIndex: 'blocked',
blob: 'blocked',
},
},
}

export class Capabilities {
Expand Down Expand Up @@ -254,6 +288,10 @@ export class Capabilities {
* @param {keyof typeof DEFAULT_CAPABILITIES} roleId
*/
async assignRole(deviceId, roleId) {
if (deviceId !== this.#ownDeviceId && roleId === LEFT_ROLE_ID) {
throw new Error('Can only assign LEFT role to your own device')
}

let fromIndex = 0
let authCoreId
try {
Expand All @@ -280,6 +318,7 @@ export class Capabilities {
if (!ownCapabilities.roleAssignment.includes(roleId)) {
throw new Error('No capability to assign role ' + roleId)
}

await this.#dataType[kCreateWithDocId](deviceId, {
schemaName: 'role',
roleId,
Expand Down
1 change: 0 additions & 1 deletion src/core-manager/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ export class CoreManager extends TypedEmitter {
* Get an array of all cores in the given namespace
*
* @param {Namespace} namespace
* @returns
*/
getCores(namespace) {
return this.#coreIndex.getByNamespace(namespace)
Expand Down
12 changes: 12 additions & 0 deletions src/mapeo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { Logger } from './logger.js'
import { kSyncState } from './sync/sync-api.js'

/** @typedef {import("@mapeo/schema").ProjectSettingsValue} ProjectValue */
/** @typedef {import('type-fest').SetNonNullable<ProjectKeys, 'encryptionKeys'>} ValidatedProjectKeys */

const CLIENT_SQLITE_FILE_NAME = 'client.db'

Expand Down Expand Up @@ -386,6 +387,7 @@ export class MapeoManager extends TypedEmitter {

/** @param {ProjectKeys} projectKeys */
#createProjectInstance(projectKeys) {
validateProjectKeys(projectKeys)
const projectId = keyToId(projectKeys.projectKey)
return new MapeoProject({
...this.#projectStorage(projectId),
Expand Down Expand Up @@ -699,3 +701,13 @@ function omitPeerProtomux(peers) {
}
)
}

/**
* @param {ProjectKeys} projectKeys
* @returns {asserts projectKeys is ValidatedProjectKeys}
*/
function validateProjectKeys(projectKeys) {
if (!projectKeys.encryptionKeys) {
throw new Error('encryptionKeys should not be undefined')
}
}
112 changes: 108 additions & 4 deletions src/mapeo-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { DataType, kCreateWithDocId } from './datatype/index.js'
import { BlobStore } from './blob-store/index.js'
import { BlobApi } from './blob-api.js'
import { IndexWriter } from './index-writer/index.js'
import { projectSettingsTable } from './schema/client.js'
import { projectKeysTable, projectSettingsTable } from './schema/client.js'
import {
coreOwnershipTable,
deviceInfoTable,
Expand All @@ -28,9 +28,16 @@ import {
getWinner,
mapAndValidateCoreOwnership,
} from './core-ownership.js'
import { Capabilities } from './capabilities.js'
import {
COORDINATOR_ROLE_ID,
CREATOR_CAPABILITIES,
Capabilities,
DEFAULT_CAPABILITIES,
LEFT_ROLE_ID,
} from './capabilities.js'
import {
getDeviceId,
projectIdToNonce,
projectKeyToId,
projectKeyToPublicId,
valueOf,
Expand All @@ -39,6 +46,8 @@ import { MemberApi } from './member-api.js'
import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js'
import { Logger } from './logger.js'
import { IconApi } from './icon-api.js'
import { ProjectKeys } from './generated/keys.js'
import { eq } from 'drizzle-orm'

/** @typedef {Omit<import('@mapeo/schema').ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */

Expand Down Expand Up @@ -71,6 +80,10 @@ export class MapeoProject extends TypedEmitter {
#iconApi
#syncApi
#l
#sharedDb
#keyManager
#projectSecretKey
#encryptionKeys

static EMPTY_PROJECT_SETTINGS = EMPTY_PROJECT_SETTINGS

Expand All @@ -81,7 +94,7 @@ export class MapeoProject extends TypedEmitter {
* @param {import('@mapeo/crypto').KeyManager} opts.keyManager mapeo/crypto KeyManager instance
* @param {Buffer} opts.projectKey 32-byte public key of the project creator core
* @param {Buffer} [opts.projectSecretKey] 32-byte secret key of the project creator core
* @param {Partial<Record<import('./core-manager/index.js').Namespace, Buffer>>} [opts.encryptionKeys] Encryption keys for each namespace
* @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys Encryption keys for each namespace
* @param {import('drizzle-orm/better-sqlite3').BetterSQLite3Database} opts.sharedDb
* @param {IndexWriter} opts.sharedIndexWriter
* @param {import('./types.js').CoreStorage} opts.coreStorage Folder to store all hypercore data
Expand All @@ -108,7 +121,11 @@ export class MapeoProject extends TypedEmitter {

this.#l = Logger.create('project', logger)
this.#deviceId = getDeviceId(keyManager)
this.#keyManager = keyManager
this.#projectId = projectKeyToId(projectKey)
this.#projectSecretKey = projectSecretKey
this.#encryptionKeys = encryptionKeys
this.#sharedDb = sharedDb

///////// 1. Setup database
this.#sqlite = new Database(dbPath)
Expand Down Expand Up @@ -250,7 +267,6 @@ export class MapeoProject extends TypedEmitter {
deviceId: this.#deviceId,
capabilities: this.#capabilities,
coreOwnership: this.#coreOwnership,
// @ts-expect-error
encryptionKeys,
projectKey,
rpc: localPeers,
Expand Down Expand Up @@ -548,6 +564,67 @@ export class MapeoProject extends TypedEmitter {
get $icons() {
return this.#iconApi
}

async $leave() {
const canLeaveProject = await deviceCanLeaveProject(
this.#deviceId,
this.#capabilities
)

if (!canLeaveProject) {
throw new Error(
'Cannot leave a project that does not have an external creator or another coordinator'
)
}

// update client database (delete all encryption keys except auth)
const encoded = ProjectKeys.encode({
projectKey: Buffer.from(this.#projectId, 'hex'),
projectSecretKey: this.#projectSecretKey,
encryptionKeys: { auth: this.#encryptionKeys.auth },
}).finish()

const nonce = projectIdToNonce(this.#projectId)

this.#sharedDb
.update(projectKeysTable)
.set({
keysCipher: this.#keyManager.encryptLocalMessage(
Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength),
nonce
),
})
.where(eq(projectKeysTable.projectId, this.#projectId))
.run()

// stop indexers? (multi-core and sqlite)
// clear data from project database

// clear data from cores
// TODO: only clear synced data
const namespacePromises = []
for (const namespace of /** @type {import('./core-manager/core-index.js').Namespace[]} */ ([
'config',
'data',
'blobs',
'blobIndex',
])) {
const deletionPromises = []
const coreRecords = this.#coreManager.getCores(namespace)

for (const { core } of coreRecords) {
deletionPromises.push(core.purge())
}

namespacePromises.push(Promise.all(deletionPromises))
}

await Promise.all(namespacePromises)

// TODO: update sync mode to "unsynced-data-background-sync"?

await this.#capabilities.assignRole(this.#deviceId, LEFT_ROLE_ID)
}
}

/**
Expand Down Expand Up @@ -603,3 +680,30 @@ function mapAndValidateDeviceInfo(doc, { coreDiscoveryKey }) {
}
return doc
}

/**
* @param {string} deviceId
* @param {import('./capabilities.js').Capabilities} capabilities
*/
async function deviceCanLeaveProject(deviceId, capabilities) {
// Check that we're allowed to leave the project
const deviceIdToCaps = await capabilities.getAll()

const deviceIdToCapsList = Object.entries(deviceIdToCaps)

// Check if the the device of interest is the only one for the project
if (deviceId in deviceIdToCaps && deviceIdToCapsList.length === 1) {
return false
}

return deviceIdToCapsList.some(([id, cap]) => {
// Skip device of interest
if (id === deviceId) return false

// Only allowed to leave a project where we know that an external creator or a coordinator that isn't ourself exists
const isCoordinator = cap === DEFAULT_CAPABILITIES[COORDINATOR_ROLE_ID]
const isCreator = cap === CREATOR_CAPABILITIES

return isCoordinator || isCreator
})
}
Loading

0 comments on commit dd58aea

Please sign in to comment.