Skip to content
Draft
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
48 changes: 35 additions & 13 deletions packages/auth/src/team/Team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class Team extends EventEmitter<TeamEvents> {
private readonly context: LocalUserContext
private readonly log: (o: any, ...args: any[]) => void
private readonly seed: string
private readonly selfAssignRoles: string[]

/**
* We can make a team instance either by creating a brand-new team, or restoring one from a stored graph.
Expand Down Expand Up @@ -139,6 +140,7 @@ export class Team extends EventEmitter<TeamEvents> {
}

this.state = this.store.getState()
this.selfAssignRoles = options.selfAssignRoles ?? []

// Wire up event listeners
this.on('updated', () => {
Expand Down Expand Up @@ -348,10 +350,10 @@ export class Team extends EventEmitter<TeamEvents> {
}

/** Give a member a role */
public addMemberRole = (userId: string, roleName: string) => {
public addMemberRole = (userId: string, roleName: string, decryptionKeys?: KeysetWithSecrets) => {
// Make a lockbox for the role
const member = this.members(userId)
const allGenKeys = this.roleKeysAllGenerations(roleName)
const allGenKeys = this.roleKeysAllGenerations(roleName, decryptionKeys)
const lockboxRoleKeysForMember = allGenKeys.map(roleKeys => lockbox.create(roleKeys, member.keys))

// Post the member role to the graph
Expand All @@ -361,6 +363,12 @@ export class Team extends EventEmitter<TeamEvents> {
})
}

/** Give yourself a role */
public addMemberRoleToSelf = (roleName: string, decryptionKeys: KeysetWithSecrets) => {
// assert(this.selfAssignRoles.includes(roleName), `Cannot self-assign role ${roleName}`)
this.addMemberRole(this.userId, roleName, decryptionKeys)
}

/** Remove a role from a member */
public removeMemberRole = (userId: string, roleName: string) => {
if (roleName === ADMIN) {
Expand Down Expand Up @@ -796,22 +804,22 @@ export class Team extends EventEmitter<TeamEvents> {
* get other members' public keys, look up the member - the `keys` property contains their public
* keys.
*/
public keys = (scope: KeyMetadata | KeyScope) =>
select.keys(this.state, this.context.device.keys, scope)
public keys = (scope: KeyMetadata | KeyScope, decryptionKeys: KeysetWithSecrets = this.context.device.keys) =>
select.keys(this.state, decryptionKeys, scope)

public keysAllGenerations = (scope: KeyMetadata | KeyScope) =>
select.keysAllGen(this.state, this.context.device.keys, scope)
public keysAllGenerations = (scope: KeyMetadata | KeyScope, decryptionKeys: KeysetWithSecrets = this.context.device.keys) =>
select.keysAllGen(this.state, decryptionKeys, scope)

public allKeys = () =>
select.allKeys(this.state, this.context.device.keys)
public allKeys = (decryptionKeys: KeysetWithSecrets = this.context.device.keys) =>
select.allKeys(this.state, decryptionKeys)

/** Returns the keys for the given role. */
public roleKeys = (roleName: string, generation?: number) =>
this.keys({ type: KeyType.ROLE, name: roleName, generation })
public roleKeys = (roleName: string, generation?: number, decryptionKeys?: KeysetWithSecrets) =>
this.keys({ type: KeyType.ROLE, name: roleName, generation }, decryptionKeys)

/** Returns the keys for the given role. */
public roleKeysAllGenerations = (roleName: string) =>
this.keysAllGenerations({ type: KeyType.ROLE, name: roleName })
public roleKeysAllGenerations = (roleName: string, decryptionKeys?: KeysetWithSecrets) =>
this.keysAllGenerations({ type: KeyType.ROLE, name: roleName }, decryptionKeys)

/** Returns the current team keys or a specific generation of team keys */
public teamKeys = (generation?: number) => this.keys({ ...TEAM_SCOPE, generation })
Expand Down Expand Up @@ -850,6 +858,20 @@ export class Team extends EventEmitter<TeamEvents> {
if (isForServer) device.keys = newKeys // (a server plays the role of both a user and a device)
}

/**
* Create a new lockbox containing a role's current generation keys encrypted to an arbitrary keyset
*
* @param roleName Role whose keys we want to encapsulate in the lockbox (must be a role the user has!)
* @param encryptionKeys Keys to encrypt the lockbox to
* @returns Generated lockbox
*/
public createLockbox = (roleName: string, encryptionKeys: KeysetWithSecrets): lockbox.Lockbox[] => {
const roleKeys = this.roleKeysAllGenerations(roleName)
const lockboxes = roleKeys.map((keys) => lockbox.create(keys, encryptionKeys))
this.dispatch({ type: 'ADD_LOCKBOXES', payload: { lockboxes }})
return lockboxes
}

private checkForPendingKeyRotations() {
// Only admins can rotate keys
if (!this.memberIsAdmin(this.userId)) {
Expand All @@ -868,7 +890,7 @@ export class Team extends EventEmitter<TeamEvents> {
}

private readonly createMemberLockboxes = (member: Member) => {
const roleKeys = member.roles.map(this.roleKeys)
const roleKeys = member.roles.map((roleName: string) => this.roleKeys(roleName))
const createLockboxRoleKeysForMember = (keys: KeysetWithSecrets) => {
return lockbox.create(keys, member.keys)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/auth/src/team/createTeam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { TEAM_SCOPE } from './constants.js'
import { type LocalContext } from 'team/context.js'
import { Team } from 'team/Team.js'

export function createTeam(teamName: string, context: LocalContext, seed?: string) {
export function createTeam(teamName: string, context: LocalContext, seed?: string, selfAssignRoles?: string[]) {
const teamKeys = createKeyset(TEAM_SCOPE, seed)

return new Team({ teamName, context, teamKeys })
return new Team({ teamName, context, teamKeys, selfAssignRoles })
}
5 changes: 5 additions & 0 deletions packages/auth/src/team/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ const getTransforms = (action: TeamAction): Transform[] => {
]
}

case 'ADD_LOCKBOXES': {
const { lockboxes } = action.payload
return [(state) => state]
}

default: {
// ignore coverage
throw unrecognizedLinkType(action)
Expand Down
71 changes: 70 additions & 1 deletion packages/auth/src/team/test/roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { ADMIN } from 'role/index.js'
import * as teams from 'team/index.js'
import { setup } from 'util/testing/index.js'
import 'util/testing/expect/toLookLikeKeyset.js'
import { symmetric } from '@localfirst/crypto'
import { randomBytes, symmetric } from '@localfirst/crypto'
import { describe, expect, it } from 'vitest'
import { randomUUID } from 'crypto'
import { createKeyset, KeyScope } from '@localfirst/crdx'

const MANAGERS = 'managers'
const managers = { roleName: MANAGERS }
Expand Down Expand Up @@ -112,6 +114,73 @@ describe('Team', () => {
expect(bobLooksForAdminKeys).toThrow()
})

it('self-assigns a role', () => {
const { alice, bob } = setup('alice', 'bob')

// 👩🏾 Alice creates MEMBER role
alice.team.addRole('MEMBER')
alice.team.addMemberRole(alice.userId, 'MEMBER')

// 👩🏾 Alice is a MEMBER
expect(alice.team.hasRole('MEMBER')).toBe(true)
expect(alice.team.memberHasRole(alice.userId, 'MEMBER')).toBe(true)

// 👩🏾 Alice creates a lockbox for MEMBER keys under arbitrary keys
const randomSeed = randomUUID()
const arbitraryScope: KeyScope = { type: 'TESTING', name: 'TESTING' }
const keySet = createKeyset(arbitraryScope, randomSeed)
alice.team.createLockbox('MEMBER', keySet)

// 👩🏾 Alice persists the team
const savedTeam = alice.team.save()

// 👨🏻‍🦲 Bob loads the team
bob.team = teams.load(savedTeam, bob.localContext, alice.team.teamKeys())

// 👨🏻‍🦲 Bob doesn't have the MEMBER role
expect(bob.team.memberHasRole(bob.userId, 'MEMBER')).toBe(false)

// 👨🏻‍🦲 Bob self-assigns the MEMBER role
bob.team.addMemberRoleToSelf('MEMBER', keySet)

// 👨🏻‍🦲 Bob has the MEMBER role keys
const bobsMemberKeys = bob.team.roleKeys('MEMBER')
expect(bobsMemberKeys).toLookLikeKeyset()
})

it(`attempts to self-assign a role that can't be self-assigned`, () => {
const { alice, bob } = setup('alice', 'bob')

// 👩🏾 Alice creates FOOBAR role
alice.team.addRole('FOOBAR')
alice.team.addMemberRole(alice.userId, 'FOOBAR')

// 👩🏾 Alice is a FOOBAR
expect(alice.team.hasRole('FOOBAR')).toBe(true)
expect(alice.team.memberHasRole(alice.userId, 'FOOBAR')).toBe(true)

// 👩🏾 Alice creates a lockbox for FOOBAR keys under arbitrary keys
const randomSeed = randomUUID()
const arbitraryScope: KeyScope = { type: 'TESTING', name: 'TESTING' }
const keySet = createKeyset(arbitraryScope, randomSeed)
alice.team.createLockbox('FOOBAR', keySet)

// 👩🏾 Alice persists the team
const savedTeam = alice.team.save()

// 👨🏻‍🦲 Bob loads the team
bob.team = teams.load(savedTeam, bob.localContext, alice.team.teamKeys())

// 👨🏻‍🦲 Bob doesn't have the FOOBAR role
expect(bob.team.memberHasRole(bob.userId, 'FOOBAR')).toBe(false)

// 👨🏻‍🦲 Bob attempts to self-assign the FOOBAR role
const attemptToSelfAssignRole = () => {
bob.team.addMemberRoleToSelf('FOOBAR', keySet)
}
expect(attemptToSelfAssignRole).toThrow()
})

it('removes a role', () => {
const { alice } = setup('alice')

Expand Down
11 changes: 11 additions & 0 deletions packages/auth/src/team/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export type TeamOptions = NewOrExisting & {

/** Object containing the current user and device (and optionally information about the client & version). */
context: LocalContext

/** Roles that can be self-assigned by a member on the chain */
selfAssignRoles?: string[]
}

/** Type guard for NewTeamOptions vs ExistingTeamOptions */
Expand Down Expand Up @@ -238,6 +241,13 @@ export type SetTeamNameAction = {
}
}

export type AddLockboxesAction = {
type: 'ADD_LOCKBOXES'
payload: BasePayload & {
lockboxes: Lockbox[]
}
}

export type TeamAction =
| RootAction
| AddMemberAction
Expand All @@ -260,6 +270,7 @@ export type TeamAction =
| ChangeServerKeysAction
| MessageAction
| SetTeamNameAction
| AddLockboxesAction

export type TeamContext = {
deviceId: string
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/src/util/testing/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const setup = (..._config: SetupConfig) => {
const founderContext = { user: testUsers[founder], device: laptops[founder] }
const teamName = 'Spies Я Us'
const randomSeed = teamName
const team = teams.createTeam(teamName, founderContext, randomSeed)
const team = teams.createTeam(teamName, founderContext, randomSeed, ['MEMBER'])
const teamKeys = team.teamKeys()

// Add members
Expand Down