Skip to content
2 changes: 1 addition & 1 deletion packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
12 changes: 11 additions & 1 deletion packages/auth/src/role/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
export type PermissionsMap = Record<string, boolean>
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
Expand Down
60 changes: 53 additions & 7 deletions packages/auth/src/team/Team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -53,7 +53,7 @@ import type {
TeamState,
} from './types.js'
import { isNewTeam } from './types.js'
import { canUserAddMemberToRole } from './validate.js'
import { canUserAddMemberToRole, canUserAddMemberToStaticRole } from './validate.js'
import { isAdminOnlyActionType } from './isAdminOnlyAction.js'

const { DEVICE, USER } = KeyType
Expand Down Expand Up @@ -354,8 +354,9 @@ export class Team extends EventEmitter<TeamEvents> {
}

// 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 },
})

Expand Down Expand Up @@ -385,8 +386,10 @@ export class Team extends EventEmitter<TeamEvents> {

/** 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 },
})
}
Expand Down Expand Up @@ -435,28 +438,46 @@ export class Team extends EventEmitter<TeamEvents> {

/** Check if member has permissions to add members to a role */
public memberCanAddMembersToRole(roleName: string, memberId: string): boolean {
if (!this._memberHasPrivelegeToPerformAction(memberId, 'ADD_MEMBER_ROLE')) {
const role = this.roles(roleName)
const isRoleStatic = this._isRoleStatic(role)
const action: TeamAction['type'] = isRoleStatic ? 'ADD_MEMBER_STATIC_ROLE' : 'ADD_MEMBER_ROLE'
if (!this._memberHasPrivelegeToPerformAction(memberId, action)) {
return false
}
if (!this.memberHasRole(memberId, roleName)) {
return false
}
return canUserAddMemberToRole(roleName, memberId, this.state)
if (isRoleStatic) {
return canUserAddMemberToStaticRole(role, memberId, this.state).valid
}
return canUserAddMemberToRole(role, memberId, this.state)
}

/** Check if member has permissions to reevoke membership from a role */
public memberCanRemoveMembersFromRole(roleName: string, memberId: string): boolean {
if (!this._memberHasPrivelegeToPerformAction(memberId, 'REMOVE_MEMBER_ROLE')) {
return false
}
return this.memberHasRole(memberId, roleName)
if (!this.memberHasRole(memberId, roleName)) {
return false
}
const isRoleStatic = this._isRoleStatic(roleName)
if (isRoleStatic) {
return false
}
return true
}

/** Check if member has permissions to create roles */
public memberCanCreateRole(memberId: string): boolean {
return this._memberHasPrivelegeToPerformAction(memberId, 'ADD_ROLE')
}

/** Check if member has permissions to create static roles */
public memberCanCreateStaticRole(memberId: string): boolean {
return this._memberHasPrivelegeToPerformAction(memberId, 'ADD_STATIC_ROLE')
}

/** Check if member has permissions to delete a specific role */
public memberCanDeleteRole(roleName: string, memberId: string): boolean {
if (!this._memberHasPrivelegeToPerformAction(memberId, 'REMOVE_ROLE')) {
Expand All @@ -468,6 +489,31 @@ export class Team extends EventEmitter<TeamEvents> {
return this._isRoleRemovable(roleName, false)
}

private _isRoleStatic(roleName: string): boolean
private _isRoleStatic(role: Role): boolean
private _isRoleStatic(roleNameOrRole: string | Role): boolean {
let role: Role
if (typeof roleNameOrRole === 'string') {
role = this.roles(roleNameOrRole)
} else {
role = roleNameOrRole
}

if (role.permissions == null) {
return false
}

if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] == null) {
return false
}

if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true) {
return false
}

return true
}

/** ************** DEVICES */

/** Returns true if the given member has a device by the given name */
Expand Down
2 changes: 2 additions & 0 deletions packages/auth/src/team/isAdminOnlyAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const isAdminOnlyActionType = (actionType: TeamAction['type']): boolean =
'ADMIT_MEMBER',
'ADMIT_DEVICE',
'ADD_MEMBER_ROLE',
'ADD_STATIC_ROLE',
'ADD_MEMBER_STATIC_ROLE',
'SET_METADATA',
]

Expand Down
10 changes: 8 additions & 2 deletions packages/auth/src/team/membershipResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type TeamLink,
type TeamGraph,
type AdmitMemberAction,
type AddMemberStaticRoleAction,
} from 'team/types.js'
import { arraysAreEqual } from 'util/arraysAreEqual.js'

Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand All @@ -185,4 +191,4 @@ const usesInvitation = (invitation: Invitation) => (l: TeamLink) =>
l.body.payload.id === invitation.id

type RemoveActionLink = Link<RemoveMemberAction | RemoveMemberRoleAction, TeamContext>
type AddActionLink = Link<AddMemberAction | AddMemberRoleAction | AdmitMemberAction, TeamContext>
type AddActionLink = Link<AddMemberAction | AddMemberRoleAction | AddMemberStaticRoleAction | AdmitMemberAction, TeamContext>
16 changes: 15 additions & 1 deletion packages/auth/src/team/reducer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 [
Expand Down
20 changes: 19 additions & 1 deletion packages/auth/src/team/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 & {
Expand All @@ -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 & {
Expand Down Expand Up @@ -269,6 +283,8 @@ export type TeamAction =
| RemoveDeviceAction
| RemoveRoleAction
| RemoveMemberRoleAction
| AddStaticRoleAction
| AddMemberStaticRoleAction
| InviteMemberAction
| InviteDeviceAction
| RevokeInvitationAction
Expand Down Expand Up @@ -367,3 +383,5 @@ export type LookupIdentityResult =
export type EncryptStreamTeamPayload = { recipient: KeyMetadata, encryptStream: AsyncGenerator<Uint8Array>, header: Uint8Array }

export type TeamMetadata = { selfAssignableRoles: string[] }

export type ValidationFuncResult = { valid: boolean, reason?: string }
61 changes: 56 additions & 5 deletions packages/auth/src/team/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
type TeamState,
type TeamStateValidator,
type TeamStateValidatorSet,
type ValidationFuncResult,
} from './types.js'
import { Permission, type ModifiableMembershipPermissionConfig, type Role } 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' })
Expand All @@ -25,17 +27,30 @@ export const validate: TeamStateValidator = (previousState: TeamState, link: Tea
return VALID
}

export const canUserAddMemberToRole = (roleName: string, assigningUserId: string, previousState: TeamState): boolean => {
const metadata = select.getMetadata(previousState)
if (metadata.selfAssignableRoles.includes(roleName)) {
export const canUserAddMemberToRole = (role: Role, assigningUserId: string, teamState: TeamState): boolean => {
const metadata = select.getMetadata(teamState)
if (metadata.selfAssignableRoles.includes(role.roleName)) {
return true
}
if (select.memberIsAdmin(previousState, assigningUserId)) {
if (select.memberIsAdmin(teamState, assigningUserId)) {
return true
}
return false
}

export const canUserAddMemberToStaticRole = (role: Role, assigningUserId: string, teamState: TeamState): ValidationFuncResult => {
if (assigningUserId !== role.createdBy) {
return { valid: false, reason: `User ${assigningUserId} attempted to assign role ${role.roleName} but they are not the owner of this role` }
}
if (role.permissions == null) {
return { valid: false, reason: `User ${assigningUserId} attempted to assign role ${role.roleName} to member using ADD_MEMBER_STATIC_ROLE but the role has no permissions` }
}
if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] == null || role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true) {
return { valid: false, reason: `User ${assigningUserId} attempted to assign role ${role.roleName} to member using ADD_MEMBER_STATIC_ROLE but the role is not static` }
}
return { valid: true }
}

const validators: TeamStateValidatorSet = {
rootDeviceBelongsToRootUser(previousState: TeamState, link: TeamLink, extendableLogger: Logger) {
const logger = extendableLogger.extend('rootDeviceBelongsToRootUser')
Expand Down Expand Up @@ -129,11 +144,47 @@ const validators: TeamStateValidatorSet = {
if (link.body.type === 'ADD_MEMBER_ROLE') {
const { userId: assigningUserId } = link.body
const { roleName } = link.body.payload
if (canUserAddMemberToRole(roleName, assigningUserId, previousState)) return VALID
const role = select.role(previousState, roleName)
if (canUserAddMemberToRole(role, assigningUserId, previousState)) return VALID
return fail(`User ${assigningUserId} attempted to assign role ${roleName} illegally`, previousState, link, logger)
}
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: targetUserId, roleName } = link.body.payload
const role = select.role(previousState, roleName)
const { valid, reason } = canUserAddMemberToStaticRole(role, assigningUserId, previousState)
if (valid && role.permissions != null && (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] as ModifiableMembershipPermissionConfig).memberIds.includes(targetUserId)) {
return VALID
}
const failureReason = reason ?? `User ${assigningUserId} attempted to assign role ${role.roleName} to member ${targetUserId} not specified in role's permissions`
return fail(failureReason, previousState, link, logger)
}
return VALID
},
}

const fail = (message: string, previousState: TeamState, link: TeamLink, extendableLogger: Logger) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/auth/src/util/actionFingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
Expand Down