Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add capabilities #231

Merged
merged 1 commit into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
90 changes: 89 additions & 1 deletion drizzle/project/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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": {},
Expand Down
4 changes: 2 additions & 2 deletions drizzle/project/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1693141552742,
"tag": "0000_absent_medusa",
"when": 1693481966662,
"tag": "0000_volatile_jackal",
"breakpoints": true
}
]
Expand Down
207 changes: 207 additions & 0 deletions src/capabilities.js
Original file line number Diff line number Diff line change
@@ -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<import('@mapeo/schema').MapeoDoc['schemaName'], DocCapability>} 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<RoleId, Capability>} */
export const DEFAULT_CAPABILITIES = {
[MEMBER_ROLE_ID]: {
name: 'Member',
docs: mapObject(currentSchemaVersions, (key) => {
return [
key,
{ readOwn: true, writeOwn: true, readOthers: true, writeOthers: false },
]
}),
Comment on lines +53 to +58
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's happening here with these object mappings? hard to tell at a glance without needing to dive into the currentSchemaVersions code

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<Capability>}
*/
async getCapabilities(deviceId) {
let roleId
try {
const roleAssignment = await this.#dataType.getByDocId(deviceId)
roleId = roleAssignment.roleId
} catch (e) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eslint doesn't complain about this being unused?

Suggested change
} catch (e) {
} catch {

// 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
Comment on lines +174 to +175
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

somewhat of a nit but initially read this as assigning a role called project creator

Suggested change
const isAssigningProjectCreatorRole =
authCoreId === this.#projectCreatorAuthCoreId
const isAssigningToProjectCreator =
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
}
2 changes: 1 addition & 1 deletion src/core-ownership.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class CoreOwnership {
}),
}
const docId = identityKeypair.publicKey.toString('hex')
this.#dataType[kCreateWithDocId](docId, docValue)
await this.#dataType[kCreateWithDocId](docId, docValue)
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/datastore/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
})

/**
Expand Down
Loading