diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 1351c6fe..23425756 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -66,6 +66,7 @@ export class Team extends EventEmitter { 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. @@ -139,6 +140,7 @@ export class Team extends EventEmitter { } this.state = this.store.getState() + this.selfAssignRoles = options.selfAssignRoles ?? [] // Wire up event listeners this.on('updated', () => { @@ -348,10 +350,10 @@ export class Team extends EventEmitter { } /** 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 @@ -361,6 +363,12 @@ export class Team extends EventEmitter { }) } + /** 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) { @@ -796,22 +804,22 @@ export class Team extends EventEmitter { * 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 }) @@ -850,6 +858,20 @@ export class Team extends EventEmitter { 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)) { @@ -868,7 +890,7 @@ export class Team extends EventEmitter { } 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) } diff --git a/packages/auth/src/team/createTeam.ts b/packages/auth/src/team/createTeam.ts index b596a238..755cd3f8 100644 --- a/packages/auth/src/team/createTeam.ts +++ b/packages/auth/src/team/createTeam.ts @@ -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 }) } diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index 4527e4fe..d17cc4bf 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -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) diff --git a/packages/auth/src/team/test/roles.test.ts b/packages/auth/src/team/test/roles.test.ts index 2f89b33e..c043d266 100644 --- a/packages/auth/src/team/test/roles.test.ts +++ b/packages/auth/src/team/test/roles.test.ts @@ -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 } @@ -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') diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 6e8f765c..9c0e281f 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -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 */ @@ -238,6 +241,13 @@ export type SetTeamNameAction = { } } +export type AddLockboxesAction = { + type: 'ADD_LOCKBOXES' + payload: BasePayload & { + lockboxes: Lockbox[] + } +} + export type TeamAction = | RootAction | AddMemberAction @@ -260,6 +270,7 @@ export type TeamAction = | ChangeServerKeysAction | MessageAction | SetTeamNameAction + | AddLockboxesAction export type TeamContext = { deviceId: string diff --git a/packages/auth/src/util/testing/setup.ts b/packages/auth/src/util/testing/setup.ts index 22918f11..074f56f5 100644 --- a/packages/auth/src/util/testing/setup.ts +++ b/packages/auth/src/util/testing/setup.ts @@ -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