Skip to content

Commit

Permalink
feat: validate role records
Browse files Browse the repository at this point in the history
When fetching a role, we now validate that the role is valid, returning
a blocked role if it's not.

See [#188] for more details.

[0]: #188
  • Loading branch information
EvanHahn committed Nov 19, 2024
1 parent 1a0af6d commit 335f048
Show file tree
Hide file tree
Showing 5 changed files with 291 additions and 30 deletions.
36 changes: 28 additions & 8 deletions src/datatype/get-if-exists.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import { NotFoundError } from '../errors.js'
/** @import { MapeoDocMap, MapeoValueMap } from '../types.js' */
/** @import { DataType, MapeoDocTables } from './index.js' */

/**
* @template T
* @param {() => PromiseLike<T>} fn
* @returns {Promise<null | T>}
*/
async function nullIfNotFound(fn) {
try {
return await fn()
} catch (err) {
if (err instanceof NotFoundError) return null
throw err
}
}

/**
* @template {MapeoDocTables} TTable
* @template {TTable['_']['name']} TSchemaName
Expand All @@ -12,11 +26,17 @@ import { NotFoundError } from '../errors.js'
* @param {string} docId
* @returns {Promise<null | TDoc & { forks: string[] }>}
*/
export async function getByDocIdIfExists(dataType, docId) {
try {
return await dataType.getByDocId(docId)
} catch (err) {
if (err instanceof NotFoundError) return null
throw err
}
}
export const getByDocIdIfExists = (dataType, docId) =>
nullIfNotFound(() => dataType.getByDocId(docId))

/**
* @template {MapeoDocTables} TTable
* @template {TTable['_']['name']} TSchemaName
* @template {MapeoDocMap[TSchemaName]} TDoc
* @template {MapeoValueMap[TSchemaName]} TValue
* @param {DataType<DataStore, TTable, TSchemaName, TDoc, TValue>} dataType
* @param {string} versionId
* @returns {Promise<null | TDoc>}
*/
export const getByVersionIdIfExists = (dataType, versionId) =>
nullIfNotFound(() => dataType.getByVersionId(versionId))
6 changes: 4 additions & 2 deletions src/member-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -496,10 +496,12 @@ export class MemberApi extends TypedEmitter {
/**
* @param {string} deviceId
* @param {import('./roles.js').RoleIdAssignableToOthers} roleId
* @param {object} [options]
* @param {boolean} [options.__testOnlyAllowAnyRoleToBeAssigned]
* @returns {Promise<void>}
*/
async assignRole(deviceId, roleId) {
return this.#roles.assignRole(deviceId, roleId)
async assignRole(deviceId, roleId, options) {
return this.#roles.assignRole(deviceId, roleId, options)
}
}

Expand Down
196 changes: 178 additions & 18 deletions src/roles.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { currentSchemaVersions } from '@comapeo/schema'
import { currentSchemaVersions, parseVersionId } from '@comapeo/schema'
import mapObject from 'map-obj'
import { kCreateWithDocId, kDataStore } from './datatype/index.js'
import { assert, setHas } from './utils.js'
import { getByDocIdIfExists } from './datatype/get-if-exists.js'
import {
getByDocIdIfExists,
getByVersionIdIfExists,
} from './datatype/get-if-exists.js'
import { TypedEmitter } from 'tiny-typed-emitter'
/** @import { Role as RoleRecord } from '@comapeo/schema' */
/** @import { ReadonlyDeep } from 'type-fest' */
/** @import { Namespace } from './types.js' */

// Randomly generated 8-byte encoded as hex
Expand All @@ -14,6 +19,8 @@ export const BLOCKED_ROLE_ID = '9e6d29263cba36c9'
export const LEFT_ROLE_ID = '8ced989b1904606b'
export const NO_ROLE_ID = '08e4251e36f6e7ed'

const CREATOR_ROLE_RECORD = Symbol('creator role assignment')

/**
* @typedef {T extends Iterable<infer U> ? U : never} ElementOf
* @template T
Expand Down Expand Up @@ -270,24 +277,143 @@ export class Roles extends TypedEmitter {
* @returns {Promise<Role>}
*/
async getRole(deviceId) {
const roleRecord = await getByDocIdIfExists(this.#dataType, deviceId)
if (!roleRecord) {
// The project creator will have the creator role
const authCoreId = await this.#coreOwnership.getCoreId(deviceId, 'auth')
if (authCoreId === this.#projectCreatorAuthCoreId) {
return CREATOR_ROLE
} else {
// When no role assignment exists, e.g. a newly added device which has
// not yet synced role records.
const roleRecord = await this.#getRoleRecord(deviceId)

switch (roleRecord) {
case null:
return NO_ROLE
case CREATOR_ROLE_RECORD:
return CREATOR_ROLE
default: {
const { roleId } = roleRecord
if (isRoleId(roleId) && (await this.#isRoleChainValid(roleRecord))) {
return ROLES[roleId]
} else {
return BLOCKED_ROLE
}
}
}
}

/**
* @param {string} deviceId
* @returns {Promise<null | typeof CREATOR_ROLE_RECORD | RoleRecord>}
*/
async #getRoleRecord(deviceId) {
const result = await getByDocIdIfExists(this.#dataType, deviceId)
if (result) return result

const { roleId } = roleRecord
if (!isRoleId(roleId)) {
return BLOCKED_ROLE
// The project creator will have the creator role
const authCoreId = await this.#coreOwnership.getCoreId(deviceId, 'auth')
if (authCoreId === this.#projectCreatorAuthCoreId) {
return CREATOR_ROLE_RECORD
}
return ROLES[roleId]

// When no role assignment exists, e.g. a newly added device which has
// not yet synced role records.
return null
}

/**
* @param {ReadonlyDeep<RoleRecord>} roleRecord
* @returns {Promise<boolean>}
*/
async #isRoleChainValid(roleRecord) {
if (roleRecord.roleId === LEFT_ROLE_ID) return true

/** @type {null | ReadonlyDeep<RoleRecord>} */
let currentRoleRecord = roleRecord

while (currentRoleRecord) {
if (this.#isAssignedByProjectCreator(currentRoleRecord)) {
return true
}

const parentRoleRecord = await this.#getParentRoleRecord(
currentRoleRecord
)
switch (parentRoleRecord) {
case null:
break
case CREATOR_ROLE_RECORD:
return true
default:
if (
!canAssign({
assigner: parentRoleRecord,
assignee: currentRoleRecord,
})
) {
return false
}
break
}

currentRoleRecord = parentRoleRecord
}

return false
}

/**
* @param {ReadonlyDeep<Pick<RoleRecord, 'versionId'>>} roleRecord
* @returns {boolean}
*/
#isAssignedByProjectCreator({ versionId }) {
const { coreDiscoveryKey } = parseVersionId(versionId)
const coreDiscoveryKeyString = coreDiscoveryKey.toString('hex')
return coreDiscoveryKeyString === this.#projectCreatorAuthCoreId
}

/**
* @param {ReadonlyDeep<RoleRecord>} roleRecord
* @returns {Promise<null | typeof CREATOR_ROLE_RECORD | RoleRecord>}
*/
async #getParentRoleRecord(roleRecord) {
const {
coreDiscoveryKey: assignerCoreDiscoveryKey,
index: assignerIndexAtAssignmentTime,
} = parseVersionId(roleRecord.versionId)

const assignerCore = this.#coreManager.getCoreByDiscoveryKey(
assignerCoreDiscoveryKey
)
if (assignerCore?.namespace !== 'auth') return null

const assignerCoreId = assignerCore.key.toString('hex')
const assignerDeviceId = await this.#coreOwnership
.getOwner(assignerCoreId)
.catch(() => null)
if (!assignerDeviceId) return null

const latestRoleRecord = await this.#getRoleRecord(assignerDeviceId)

let roleRecordToCheck = latestRoleRecord
/** @type {RoleRecord[]} */ const roleRecordsToCheck = []
while (roleRecordToCheck) {
if (
roleRecordToCheck === CREATOR_ROLE_RECORD ||
(roleRecordToCheck.fromIndex <= assignerIndexAtAssignmentTime &&
roleRecordToCheck.versionId !== roleRecord.versionId)
) {
return roleRecordToCheck
}

const linkedRoleRecords = await Promise.all(
roleRecordToCheck.links.map((linkedVersionId) =>
getByVersionIdIfExists(this.#dataType, linkedVersionId)
)
)
for (const linkedRoleRecord of linkedRoleRecords) {
if (linkedRoleRecord) {
roleRecordsToCheck.push(linkedRoleRecord)
}
}

roleRecordToCheck = roleRecordsToCheck.shift() || null
}

return null
}

/**
Expand Down Expand Up @@ -344,8 +470,14 @@ export class Roles extends TypedEmitter {
*
* @param {string} deviceId
* @param {RoleIdAssignableToAnyone} roleId
* @param {object} [options]
* @param {boolean} [options.__testOnlyAllowAnyRoleToBeAssigned]
*/
async assignRole(deviceId, roleId) {
async assignRole(
deviceId,
roleId,
{ __testOnlyAllowAnyRoleToBeAssigned = false } = {}
) {
assert(
isRoleIdAssignableToAnyone(roleId),
`Role ID should be assignable to anyone but got ${roleId}`
Expand All @@ -368,7 +500,11 @@ export class Roles extends TypedEmitter {
}
const isAssigningProjectCreatorRole =
authCoreId === this.#projectCreatorAuthCoreId
if (isAssigningProjectCreatorRole && !this.#isProjectCreator()) {
if (
isAssigningProjectCreatorRole &&
!this.#isProjectCreator() &&
!__testOnlyAllowAnyRoleToBeAssigned
) {
throw new Error(
"Only the project creator can assign the project creator's role"
)
Expand All @@ -380,7 +516,10 @@ export class Roles extends TypedEmitter {
}
} else {
const ownRole = await this.getRole(this.#ownDeviceId)
if (!ownRole.roleAssignment.includes(roleId)) {
if (
!ownRole.roleAssignment.includes(roleId) &&
!__testOnlyAllowAnyRoleToBeAssigned
) {
throw new Error('Lacks permission to assign role ' + roleId)
}
}
Expand Down Expand Up @@ -412,3 +551,24 @@ export class Roles extends TypedEmitter {
return ownAuthCoreId === this.#projectCreatorAuthCoreId
}
}

/**
* @param {object} options
* @param {ReadonlyDeep<Pick<RoleRecord, 'roleId'>>} options.assigner
* @param {ReadonlyDeep<Pick<RoleRecord, 'roleId'>>} options.assignee
* @returns {boolean}
*/
function canAssign({ assigner, assignee }) {
return (
isRoleIdAssignableToOthers(assignee.roleId) &&
roleRecordToRole(assigner).roleAssignment.includes(assignee.roleId)
)
}

/**
* @param {ReadonlyDeep<Pick<RoleRecord, 'roleId'>>} roleRecord
* @returns {Role}
*/
function roleRecordToRole({ roleId }) {
return isRoleId(roleId) ? ROLES[roleId] : BLOCKED_ROLE
}
54 changes: 54 additions & 0 deletions test-e2e/members.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { randomBytes } from 'crypto'
import { once } from 'node:events'

import {
BLOCKED_ROLE_ID,
COORDINATOR_ROLE_ID,
CREATOR_ROLE,
CREATOR_ROLE_ID,
Expand Down Expand Up @@ -261,6 +262,59 @@ test('roles - creator role and role assignment', async (t) => {
)
})

test('role validation', async (t) => {
const managers = await createManagers(2, t)
const [creator, member] = managers

const disconnectPeers = connectPeers(managers)
t.after(disconnectPeers)

const projectId = await creator.createProject({ name: 'role test' })
await invite({
projectId,
invitor: creator,
invitees: [member],
roleId: MEMBER_ROLE_ID,
})

const projects = await Promise.all(
managers.map((manager) => manager.getProject(projectId))
)
const [creatorProject, memberProject] = projects
await waitForSync(projects, 'initial')

assert.equal(
(await creatorProject.$member.getById(member.deviceId)).role.roleId,
MEMBER_ROLE_ID,
'test setup: creator sees correct role for member'
)

await memberProject.$member.assignRole(member.deviceId, COORDINATOR_ROLE_ID, {
__testOnlyAllowAnyRoleToBeAssigned: true,
})
await waitForSync(projects, 'initial')

assert.equal(
(await creatorProject.$member.getById(member.deviceId)).role.roleId,
BLOCKED_ROLE_ID,
"creator sees member's bogus role assignment, and blocks them"
)

await creatorProject.$member.assignRole(member.deviceId, COORDINATOR_ROLE_ID)
assert.equal(
(await creatorProject.$member.getById(member.deviceId)).role.roleId,
COORDINATOR_ROLE_ID,
"creator can update the member's role"
)

await creatorProject.$member.assignRole(member.deviceId, MEMBER_ROLE_ID)
assert.equal(
(await creatorProject.$member.getById(member.deviceId)).role.roleId,
MEMBER_ROLE_ID,
"creator can update the member's role again"
)
})

test('roles - new device without role', async (t) => {
const [manager] = await createManagers(1, t)

Expand Down
Loading

0 comments on commit 335f048

Please sign in to comment.