diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index fa22f1eb..20ff8d0f 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -20,7 +20,7 @@ export type * from './team/context.js' export type * from './device/types.js' export type * from './invitation/types.js' export type * from './lockbox/types.js' -export type * from './role/types.js' +export * from './role/types.js' export type * from './server/types.js' export type * from './team/types.js' diff --git a/packages/auth/src/role/types.ts b/packages/auth/src/role/types.ts index 5f4c02e4..4df33664 100644 --- a/packages/auth/src/role/types.ts +++ b/packages/auth/src/role/types.ts @@ -1,4 +1,14 @@ -export type PermissionsMap = Record +export enum Permission { + MODIFIABLE_MEMBERSHIP = 'modifiable-membership', +} + +export interface ModifiableMembershipPermissionConfig { + memberIds: string[] +} + +export type PermissionsMap = { + [Permission.MODIFIABLE_MEMBERSHIP]?: true | ModifiableMembershipPermissionConfig +} export type Role = { roleName: string diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 2c555fd2..c023f9df 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -28,7 +28,7 @@ import * as invitations from 'invitation/index.js' import { type ProofOfInvitation } from 'invitation/index.js' import { normalize } from 'invitation/normalize.js' import * as lockbox from 'lockbox/index.js' -import { AddRoleInput, ADMIN, type Role } from 'role/index.js' +import { AddRoleInput, ADMIN, Permission, type Role } from 'role/index.js' import { castServer } from 'server/castServer.js' import { type Host, type Server } from 'server/types.js' import { type LocalUserContext } from 'team/context.js' @@ -352,8 +352,9 @@ export class Team extends EventEmitter { } // Post the role to the graph + const eventType = role.permissions && role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true ? 'ADD_ROLE' : 'ADD_STATIC_ROLE' this.dispatch({ - type: 'ADD_ROLE', + type: eventType, payload: { ...(role as Role), lockboxes: lockboxes }, }) @@ -374,8 +375,10 @@ export class Team extends EventEmitter { /** Dispatch the add role action */ private _dispatchAddMemberRole(userId: string, roleName: string, lockboxes: lockbox.Lockbox[]) { + const role = select.role(this.state, roleName) + const eventType = role.permissions && role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true ? 'ADD_MEMBER_ROLE' : 'ADD_MEMBER_STATIC_ROLE' this.dispatch({ - type: 'ADD_MEMBER_ROLE', + type: eventType, payload: { userId, roleName, lockboxes }, }) } diff --git a/packages/auth/src/team/isAdminOnlyAction.ts b/packages/auth/src/team/isAdminOnlyAction.ts index 9c6b4aba..566da6bd 100644 --- a/packages/auth/src/team/isAdminOnlyAction.ts +++ b/packages/auth/src/team/isAdminOnlyAction.ts @@ -10,8 +10,10 @@ export const isAdminOnlyAction = (action: TeamLinkBody) => { 'CHANGE_SERVER_KEYS', 'ADMIT_MEMBER', 'ADMIT_DEVICE', - 'ADD_MEMBER_ROLE', 'ADD_ROLE', + 'ADD_MEMBER_ROLE', + 'ADD_STATIC_ROLE', + 'ADD_MEMBER_STATIC_ROLE', 'SET_METADATA', ] diff --git a/packages/auth/src/team/membershipResolver.ts b/packages/auth/src/team/membershipResolver.ts index c8091979..b2d392d7 100644 --- a/packages/auth/src/team/membershipResolver.ts +++ b/packages/auth/src/team/membershipResolver.ts @@ -14,6 +14,7 @@ import { type TeamLink, type TeamGraph, type AdmitMemberAction, + type AddMemberStaticRoleAction, } from 'team/types.js' import { arraysAreEqual } from 'util/arraysAreEqual.js' @@ -123,7 +124,7 @@ const leastSenior = (chain: TeamGraph, userNames: string[]) => userNames.sort(bySeniority(chain)).pop()! const isAddAction = (link: TeamLink): link is AddActionLink => - ['ADD_MEMBER', 'ADD_MEMBER_ROLE', 'ADMIT_MEMBER'].includes(link.body.type) + ['ADD_MEMBER', 'ADD_MEMBER_ROLE', 'ADD_MEMBER_STATIC_ROLE', 'ADMIT_MEMBER', ].includes(link.body.type) const isRemovalAction = (link: TeamLink): boolean => link.body.type === 'REMOVE_MEMBER' @@ -168,6 +169,11 @@ const addedUserId = (link: AddActionLink): string => { return addAction.payload.userId } + case 'ADD_MEMBER_STATIC_ROLE': { + const addAction = link.body + return addAction.payload.userId + } + case 'ADMIT_MEMBER': { const addAction = link.body return addAction.payload.memberKeys.name @@ -185,4 +191,4 @@ const usesInvitation = (invitation: Invitation) => (l: TeamLink) => l.body.payload.id === invitation.id type RemoveActionLink = Link -type AddActionLink = Link +type AddActionLink = Link diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index c28d408b..b337127e 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -1,5 +1,5 @@ import { ROOT, type Reducer } from '@localfirst/crdx' -import { ADMIN } from 'role/index.js' +import { ADMIN, Permission, type Role } from 'role/index.js' import { clone, composeTransforms } from 'util/index.js' import { invalidLinkReducer } from './invalidLinkReducer.js' import { setHead } from './setHead.js' @@ -128,6 +128,20 @@ const getTransforms = (action: TeamAction): Transform[] => { ] } + case 'ADD_STATIC_ROLE': { + const newRole = action.payload + return [ + addRole(newRole), // Add this role to the team + ] + } + + case 'ADD_MEMBER_STATIC_ROLE': { + const { userId, roleName } = action.payload + return [ + ...addMemberRoles(userId, [roleName]), // Add this role to the member's list of roles + ] + } + case 'ADD_DEVICE': { const { device } = action.payload return [ diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index b3882179..7d24b6d8 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -16,7 +16,7 @@ import type { Client, LocalContext } from 'team/context.js' import type { Device } from 'device/index.js' import type { Invitation, InvitationState } from 'invitation/types.js' import type { Lockbox } from 'lockbox/index.js' -import type { PermissionsMap, Role } from 'role/index.js' +import type { PermissionsMap, Role, Permission } from 'role/index.js' import type { Host, Server } from 'server/index.js' import type { ValidationResult } from 'util/index.js' import { Logger, SharedLogger } from '@localfirst/shared' @@ -120,6 +120,11 @@ export type AddRoleAction = { payload: BasePayload & Role } +export type AddStaticRoleAction = { + type: 'ADD_STATIC_ROLE' + payload: BasePayload & Role +} + export type RemoveRoleAction = { type: 'REMOVE_ROLE' payload: BasePayload & { @@ -136,6 +141,15 @@ export type AddMemberRoleAction = { } } +export type AddMemberStaticRoleAction = { + type: 'ADD_MEMBER_STATIC_ROLE' + payload: BasePayload & { + userId: string + roleName: string + permissions?: PermissionsMap + } +} + export type RemoveMemberRoleAction = { type: 'REMOVE_MEMBER_ROLE' payload: BasePayload & { @@ -269,6 +283,8 @@ export type TeamAction = | RemoveDeviceAction | RemoveRoleAction | RemoveMemberRoleAction + | AddStaticRoleAction + | AddMemberStaticRoleAction | InviteMemberAction | InviteDeviceAction | RevokeInvitationAction diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index 6fd17114..60cf0d07 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -11,6 +11,7 @@ import { type TeamStateValidatorSet, type ValidationArgs, } from './types.js' +import { Permission } from 'role/types.js' export const validate: TeamStateValidator = (previousState: TeamState, link: TeamLink, extendableLogger?: Logger) => { const logger = extendableLogger != null ? extendableLogger.extend('validate') : new Logger({ moduleName: 'auth:validate' }) @@ -134,6 +135,45 @@ const validators: TeamStateValidatorSet = { } return VALID }, + + /** Check that members not listed in the permissions map for a role aren't added */ + cantModifyStaticRolesWithAddMemberRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('cantModifyStaticRolesWithAddMemberRole') + if (link.body.type === 'ADD_MEMBER_ROLE') { + const { userId: assigningUserId } = link.body + const { userId, roleName } = link.body.payload + const role = select.role(previousState, roleName) + if (role.permissions == null) { + return VALID + } + if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] == null || role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true) { + return VALID + } + return fail(`User ${assigningUserId} attempted to assign role ${roleName} to member ${userId} using ADD_MEMBER_ROLE but the role is static`, previousState, link, logger) + } + return VALID + }, + + /** Check that members not listed in the permissions map for a role aren't added */ + cantAddNewMembersToStaticRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('cantAddNewMembersToStaticRole') + if (link.body.type === 'ADD_MEMBER_STATIC_ROLE') { + const { userId: assigningUserId } = link.body + const { userId, roleName } = link.body.payload + const role = select.role(previousState, roleName) + if (role.permissions == null) { + return fail(`User ${assigningUserId} attempted to assign role ${roleName} to member ${userId} using ADD_MEMBER_STATIC_ROLE but the role has no permissions`, previousState, link, logger) + } + if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] == null || role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true) { + return fail(`User ${assigningUserId} attempted to assign role ${roleName} to member ${userId} using ADD_MEMBER_STATIC_ROLE but the role is not static`, previousState, link, logger) + } + if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP].memberIds.includes(userId)) { + return VALID + } + return fail(`User ${assigningUserId} attempted to assign role ${roleName} to member ${userId} not specified in role's permissions`, previousState, link, logger) + } + return VALID + }, } const fail = (message: string, previousState: TeamState, link: TeamLink, extendableLogger: Logger) => { diff --git a/packages/auth/src/util/actionFingerprint.ts b/packages/auth/src/util/actionFingerprint.ts index 980229d6..20b42409 100644 --- a/packages/auth/src/util/actionFingerprint.ts +++ b/packages/auth/src/util/actionFingerprint.ts @@ -15,11 +15,13 @@ export const actionFingerprint = (link: TeamLink) => { return action.payload.userId } - case 'ADD_ROLE': { + case 'ADD_ROLE': + case 'ADD_STATIC_ROLE': { return action.payload.roleName } case 'ADD_MEMBER_ROLE': + case 'ADD_MEMBER_STATIC_ROLE': case 'REMOVE_MEMBER_ROLE': { return `${action.payload.roleName}:${action.payload.userId}` }