diff --git a/api/src/services/block/block.service.ts b/api/src/services/block/block.service.ts index 688b5709..1488d1f2 100644 --- a/api/src/services/block/block.service.ts +++ b/api/src/services/block/block.service.ts @@ -18,6 +18,7 @@ import { MapBackgroundService, } from '../map-background/map-background.service'; import { resolveMapTheme } from '../../libs'; +import { PlaceCapabilityService } from '../place/place-capability.service'; /** Service for dealing with blocks */ @Service() @@ -32,6 +33,7 @@ export class BlockService { private roleRepository: RoleRepository, private memberRepository: MemberRepository, private mapBackgroundService: MapBackgroundService, + private placeCapabilityService: PlaceCapabilityService, ) {} public async find(blockId: number): Promise { @@ -231,73 +233,26 @@ export class BlockService { return await this.mapLocationRepository.createAvailableLocation(blockId, location); } + /** + * Reports whether a member may administer a block. + * @param blockId id of the block + * @param memberId id of the member acting + * @returns true when the member holds the classic owner capability at this block + */ public async canAdmin(blockId: number, memberId: number): Promise { - const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); - const hood = await this.getHood(blockId); - const hoodMapLocation = await this.mapLocationRepository.findPlaceIdMapLocation(hood.id); - const colonyId = hoodMapLocation.parent_place_id; - - if ( - roleAssignments.find(assignment => { - return ( - [ - this.roleRepository.roleMap.Admin, - this.roleRepository.roleMap.ColonyRepresentative, - ].includes(assignment.role_id) || - ([ - this.roleRepository.roleMap.ColonyLeader, - this.roleRepository.roleMap.ColonyDeputy, - ].includes(assignment.role_id) && - assignment.place_id === colonyId) || - ([ - this.roleRepository.roleMap.NeighborhoodDeputy, - this.roleRepository.roleMap.NeighborhoodLeader, - ].includes(assignment.role_id) && - assignment.place_id === hood.id) || - ([ - this.roleRepository.roleMap.BlockDeputy, - this.roleRepository.roleMap.BlockLeader, - ].includes(assignment.role_id) && - assignment.place_id === blockId) - ); - }) - ) { - return true; - } - return false; + const { canAdmin } = await this.placeCapabilityService.resolve(blockId, memberId); + return canAdmin; } + /** + * Reports whether a member may change a block's access rights. + * @param blockId id of the block + * @param memberId id of the member acting + * @returns true when the member holds the classic rights capability at this block + */ public async canManageAccess(blockId: number, memberId: number): Promise { - const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); - const hood = await this.getHood(blockId); - const hoodMapLocation = await this.mapLocationRepository.findPlaceIdMapLocation(hood.id); - const colonyId = hoodMapLocation.parent_place_id; - - if ( - roleAssignments.find(assignment => { - return ( - [ - this.roleRepository.roleMap.Admin, - this.roleRepository.roleMap.ColonyRepresentative, - ].includes(assignment.role_id) || - ([ - this.roleRepository.roleMap.ColonyLeader, - this.roleRepository.roleMap.ColonyDeputy, - ].includes(assignment.role_id) && - assignment.place_id === colonyId) || - ([ - this.roleRepository.roleMap.NeighborhoodDeputy, - this.roleRepository.roleMap.NeighborhoodLeader, - ].includes(assignment.role_id) && - assignment.place_id === hood.id) || - ([this.roleRepository.roleMap.BlockLeader].includes(assignment.role_id) && - assignment.place_id === blockId) - ); - }) - ) { - return true; - } - return false; + const { canManageAccess } = await this.placeCapabilityService.resolve(blockId, memberId); + return canManageAccess; } private async updateDeputyId(deputy: any): Promise { diff --git a/api/src/services/colony/colony.service.ts b/api/src/services/colony/colony.service.ts index d2b9cf2f..2da81077 100644 --- a/api/src/services/colony/colony.service.ts +++ b/api/src/services/colony/colony.service.ts @@ -7,6 +7,7 @@ import { MemberRepository, } from '../../repositories'; import { Place } from '../../types/models'; +import { PlaceCapabilityService } from '../place/place-capability.service'; import * as console from 'console'; import { includes } from 'lodash'; @@ -18,6 +19,7 @@ export class ColonyService { private roleAssignmentRepository: RoleAssignmentRepository, private roleRepository: RoleRepository, private memberRepository: MemberRepository, + private placeCapabilityService: PlaceCapabilityService, ) { } public async find(colonyId: number): Promise { @@ -138,47 +140,26 @@ export class ColonyService { }); } + /** + * Reports whether a member may administer a colony. + * @param colonyId id of the colony + * @param memberId id of the member acting + * @returns true when the member holds the classic owner capability at this colony + */ public async canAdmin(colonyId: number, memberId: number): Promise { - const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); - - if ( - roleAssignments.find(assignment => { - return ( - [ - this.roleRepository.roleMap.Admin, - this.roleRepository.roleMap.ColonyRepresentative, - ].includes(assignment.role_id) || - ([ - this.roleRepository.roleMap.ColonyLeader, - this.roleRepository.roleMap.ColonyDeputy, - ].includes(assignment.role_id) && - assignment.place_id === colonyId) - ); - }) - ) { - return true; - } - else return false; + const { canAdmin } = await this.placeCapabilityService.resolve(colonyId, memberId); + return canAdmin; } + /** + * Reports whether a member may change a colony's access rights. + * @param colonyId id of the colony + * @param memberId id of the member acting + * @returns true when the member holds the classic rights capability at this colony + */ public async canManageAccess(colonyId: number, memberId: number): Promise { - const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); - - if ( - roleAssignments.find(assignment => { - return ( - [ - this.roleRepository.roleMap.Admin, - this.roleRepository.roleMap.ColonyRepresentative, - ].includes(assignment.role_id) || - ([this.roleRepository.roleMap.ColonyLeader].includes(assignment.role_id) && - assignment.place_id === colonyId) - ); - }) - ) { - return true; - } - return false; + const { canManageAccess } = await this.placeCapabilityService.resolve(colonyId, memberId); + return canManageAccess; } private async updateDeputyId(deputy: any): Promise { diff --git a/api/src/services/hood/hood.service.ts b/api/src/services/hood/hood.service.ts index ee2477ae..fe19d5da 100644 --- a/api/src/services/hood/hood.service.ts +++ b/api/src/services/hood/hood.service.ts @@ -17,6 +17,7 @@ import { MapBackgroundService, } from '../map-background/map-background.service'; import { resolveMapTheme } from '../../libs'; +import { PlaceCapabilityService } from '../place/place-capability.service'; /** Service for dealing with blocks */ @Service() @@ -30,6 +31,7 @@ export class HoodService { private roleRepository: RoleRepository, private memberRepository: MemberRepository, private mapBackgroundService: MapBackgroundService, + private placeCapabilityService: PlaceCapabilityService, ) {} public async find(hoodId: number): Promise { @@ -207,59 +209,26 @@ export class HoodService { return { status: 'success', selectedIndex: normalizedIndex }; } + /** + * Reports whether a member may administer a neighborhood. + * @param hoodId id of the neighborhood + * @param memberId id of the member acting + * @returns true when the member holds the classic owner capability at this neighborhood + */ public async canAdmin(hoodId: number, memberId: number): Promise { - const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); - const colony = await this.getColony(hoodId); - - if ( - roleAssignments.find(assignment => { - return ( - [ - this.roleRepository.roleMap.Admin, - this.roleRepository.roleMap.ColonyRepresentative, - ].includes(assignment.role_id) || - ([ - this.roleRepository.roleMap.ColonyLeader, - this.roleRepository.roleMap.ColonyDeputy, - ].includes(assignment.role_id) && - assignment.place_id === colony.id) || - ([ - this.roleRepository.roleMap.NeighborhoodDeputy, - this.roleRepository.roleMap.NeighborhoodLeader, - ].includes(assignment.role_id) && - assignment.place_id === hoodId) - ); - }) - ) { - return true; - } - return false; + const { canAdmin } = await this.placeCapabilityService.resolve(hoodId, memberId); + return canAdmin; } + /** + * Reports whether a member may change a neighborhood's access rights. + * @param hoodId id of the neighborhood + * @param memberId id of the member acting + * @returns true when the member holds the classic rights capability at this neighborhood + */ public async canManageAccess(hoodId: number, memberId: number): Promise { - const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); - const colony = await this.getColony(hoodId); - - if ( - roleAssignments.find(assignment => { - return ( - [ - this.roleRepository.roleMap.Admin, - this.roleRepository.roleMap.ColonyRepresentative, - ].includes(assignment.role_id) || - ([ - this.roleRepository.roleMap.ColonyLeader, - this.roleRepository.roleMap.ColonyDeputy, - ].includes(assignment.role_id) && - assignment.place_id === colony.id) || - ([this.roleRepository.roleMap.NeighborhoodLeader].includes(assignment.role_id) && - assignment.place_id === hoodId) - ); - }) - ) { - return true; - } - return false; + const { canManageAccess } = await this.placeCapabilityService.resolve(hoodId, memberId); + return canManageAccess; } private async updateDeputyId(deputy: any): Promise { diff --git a/api/src/services/index.ts b/api/src/services/index.ts index cd75be78..92c36c7a 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -15,6 +15,7 @@ export * from './object/object.service'; export * from './object-instance/object-instance.service'; export * from './role/role.service'; export * from './role-assignment/role-assignment.service'; +export * from './place/place-capability.service'; export * from './place/place.service'; export * from './wallet/wallet.service'; export * from './messageboard/messageboard.service'; diff --git a/api/src/services/place/place-capability.service.spec.ts b/api/src/services/place/place-capability.service.spec.ts new file mode 100644 index 00000000..7dbcb2b6 --- /dev/null +++ b/api/src/services/place/place-capability.service.spec.ts @@ -0,0 +1,538 @@ +/* + * Importing the repository barrel eagerly constructs a real knex instance, and + * this project ships no knex configuration for the test environment. These + * specs only ever exercise mocked repositories, so the db module is stubbed out + * before anything can pull the real one in. + */ +jest.mock('../../db', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { mockDb } = require('@spec/mocks'); + return { db: mockDb, knex: mockDb.knex }; +}); + +import { Container } from 'typedi'; +import { createSpyObj } from 'jest-createspyobj'; + +import { PlaceCapabilityService } from './place-capability.service'; +import { + MapLocationRepository, + PlaceRepository, + RoleAssignmentRepository, + RoleRepository, +} from '../../repositories'; +import { MapLocation, Place, RoleAssignment } from '../../types/models'; + +/* + * The expectations below are the behaviour of the historical software, read off the + * running IVN11 guests on 2026-08-31. The classic `place`/`neighbor`/`block` CGIs share + * one resolver, `chDBCheckRights`, and it walks to the parent place for exactly one + * capability - the right to change access rights - and for nothing else. Observed: + * + * Block Leader at own block -> Update YES, Access Rights YES + * Block Deputy at own block -> Update YES, Access Rights NO + * Block Leader at sibling block -> neither + * Hood Leader at child block -> Update NO, Access Rights YES + * Colony Leader at child hood -> Update NO, Access Rights YES + * Colony Leader at grandchild -> Update NO, Access Rights YES + * Colony Leader at other colony -> neither + */ +describe('PlaceCapabilityService', () => { + const COLONY_ID = 10; + const OTHER_COLONY_ID = 11; + const HOOD_ID = 20; + const SIBLING_HOOD_ID = 21; + const BLOCK_ID = 30; + const SIBLING_BLOCK_ID = 31; + const OTHER_HOOD_BLOCK_ID = 32; + + const MEMBER_ID = 500; + + const roleMap = { + Admin: 1, + ColonyRepresentative: 2, + ColonyLeader: 3, + ColonyDeputy: 4, + NeighborhoodLeader: 5, + NeighborhoodDeputy: 6, + BlockLeader: 7, + BlockDeputy: 8, + Concierge: 9, + }; + + const places: Record> = { + [COLONY_ID]: { id: COLONY_ID, type: 'colony' }, + [OTHER_COLONY_ID]: { id: OTHER_COLONY_ID, type: 'colony' }, + [HOOD_ID]: { id: HOOD_ID, type: 'hood' }, + [SIBLING_HOOD_ID]: { id: SIBLING_HOOD_ID, type: 'hood' }, + [BLOCK_ID]: { id: BLOCK_ID, type: 'block' }, + [SIBLING_BLOCK_ID]: { id: SIBLING_BLOCK_ID, type: 'block' }, + [OTHER_HOOD_BLOCK_ID]: { id: OTHER_HOOD_BLOCK_ID, type: 'block' }, + }; + + /* colony 10 -> hoods 20, 21; hood 20 -> blocks 30, 31; hood 21 -> block 32. */ + const parents: Record = { + [HOOD_ID]: COLONY_ID, + [SIBLING_HOOD_ID]: COLONY_ID, + [BLOCK_ID]: HOOD_ID, + [SIBLING_BLOCK_ID]: HOOD_ID, + [OTHER_HOOD_BLOCK_ID]: SIBLING_HOOD_ID, + }; + + const assignment = (roleId: number, placeId: number): RoleAssignment => + ({ member_id: MEMBER_ID, role_id: roleId, place_id: placeId } as RoleAssignment); + + let mapLocationRepository: jest.Mocked; + let placeRepository: jest.Mocked; + let roleAssignmentRepository: jest.Mocked; + let roleRepository: jest.Mocked; + let service: PlaceCapabilityService; + + /** Points the member's role assignments at the given list for this test. */ + const givenAssignments = (assignments: RoleAssignment[]): void => { + roleAssignmentRepository.getByMemberId.mockResolvedValue(assignments); + }; + + beforeEach(() => { + mapLocationRepository = createSpyObj(MapLocationRepository); + placeRepository = createSpyObj(PlaceRepository); + roleAssignmentRepository = createSpyObj(RoleAssignmentRepository); + roleRepository = createSpyObj(RoleRepository); + + roleRepository.roleMap = { ...roleMap }; + + placeRepository.findById.mockImplementation( + async (placeId: number) => places[placeId] as Place, + ); + mapLocationRepository.findPlaceIdMapLocation.mockImplementation( + async (placeId: number) => + (parents[placeId] + ? ({ place_id: placeId, parent_place_id: parents[placeId] } as MapLocation) + : undefined) as MapLocation, + ); + givenAssignments([]); + + Container.reset(); + Container.set(MapLocationRepository, mapLocationRepository); + Container.set(PlaceRepository, placeRepository); + Container.set(RoleAssignmentRepository, roleAssignmentRepository); + Container.set(RoleRepository, roleRepository); + service = Container.get(PlaceCapabilityService); + }); + + describe('a member with no relevant authority', () => { + it('denies an ordinary member with no role assignments at all', async () => { + givenAssignments([]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies a member whose only role is unrelated to places', async () => { + givenAssignments([assignment(roleMap.Concierge, BLOCK_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies an unknown role id, even when it is scoped to the place', async () => { + givenAssignments([assignment(9999, BLOCK_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies a leader role that is not scoped to any place', async () => { + givenAssignments([assignment(roleMap.BlockLeader, null as unknown as number)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + }); + + describe('authority at the member\'s own place', () => { + it('grants a block leader both capabilities at their own block', async () => { + givenAssignments([assignment(roleMap.BlockLeader, BLOCK_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: true, + }); + }); + + it('grants a block deputy admin only, never access rights', async () => { + givenAssignments([assignment(roleMap.BlockDeputy, BLOCK_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: false, + }); + }); + + it('grants a neighborhood leader both capabilities at their own neighborhood', async () => { + givenAssignments([assignment(roleMap.NeighborhoodLeader, HOOD_ID)]); + expect(await service.resolve(HOOD_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: true, + }); + }); + + it('grants a neighborhood deputy admin only at their own neighborhood', async () => { + givenAssignments([assignment(roleMap.NeighborhoodDeputy, HOOD_ID)]); + expect(await service.resolve(HOOD_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: false, + }); + }); + + it('grants a colony leader both capabilities at their own colony', async () => { + givenAssignments([assignment(roleMap.ColonyLeader, COLONY_ID)]); + expect(await service.resolve(COLONY_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: true, + }); + }); + + it('grants a colony deputy admin only at their own colony', async () => { + givenAssignments([assignment(roleMap.ColonyDeputy, COLONY_ID)]); + expect(await service.resolve(COLONY_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: false, + }); + }); + + it('denies a leader whose role is scoped to the wrong level of place', async () => { + givenAssignments([assignment(roleMap.BlockLeader, HOOD_ID)]); + expect(await service.resolve(HOOD_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + }); + + describe('authority does not widen sideways or upwards', () => { + it('denies a block leader everything at a sibling block in the same neighborhood', async () => { + givenAssignments([assignment(roleMap.BlockLeader, BLOCK_ID)]); + expect(await service.resolve(SIBLING_BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies a block leader everything at a block in another neighborhood', async () => { + givenAssignments([assignment(roleMap.BlockLeader, BLOCK_ID)]); + expect(await service.resolve(OTHER_HOOD_BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies a block leader everything at their own parent neighborhood', async () => { + givenAssignments([assignment(roleMap.BlockLeader, BLOCK_ID)]); + expect(await service.resolve(HOOD_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies a neighborhood leader everything at a sibling neighborhood', async () => { + givenAssignments([assignment(roleMap.NeighborhoodLeader, HOOD_ID)]); + expect(await service.resolve(SIBLING_HOOD_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies a neighborhood leader everything at a block in a sibling neighborhood', async () => { + givenAssignments([assignment(roleMap.NeighborhoodLeader, HOOD_ID)]); + expect(await service.resolve(OTHER_HOOD_BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies a colony leader everything at an unrelated colony', async () => { + givenAssignments([assignment(roleMap.ColonyLeader, COLONY_ID)]); + expect(await service.resolve(OTHER_COLONY_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + }); + + describe('only access rights are inherited down the tree', () => { + it('gives a neighborhood leader access rights but no admin at a child block', async () => { + givenAssignments([assignment(roleMap.NeighborhoodLeader, HOOD_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('gives a neighborhood deputy access rights but no admin at a child block', async () => { + givenAssignments([assignment(roleMap.NeighborhoodDeputy, HOOD_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('gives a colony leader access rights but no admin at a child neighborhood', async () => { + givenAssignments([assignment(roleMap.ColonyLeader, COLONY_ID)]); + expect(await service.resolve(HOOD_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('gives a colony leader access rights but no admin at a grandchild block', async () => { + givenAssignments([assignment(roleMap.ColonyLeader, COLONY_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('gives a colony deputy access rights but no admin at a grandchild block', async () => { + givenAssignments([assignment(roleMap.ColonyDeputy, COLONY_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('keeps admin from the place itself while inheriting nothing extra', async () => { + givenAssignments([ + assignment(roleMap.BlockDeputy, BLOCK_ID), + assignment(roleMap.ColonyLeader, COLONY_ID), + ]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: true, + }); + }); + }); + + describe('a global administrator', () => { + it('grants an Admin both capabilities at any place', async () => { + givenAssignments([assignment(roleMap.Admin, OTHER_COLONY_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: true, + }); + }); + + it('grants an Admin both capabilities at every level of the tree', async () => { + givenAssignments([assignment(roleMap.Admin, null as unknown as number)]); + for (const placeId of [COLONY_ID, OTHER_COLONY_ID, HOOD_ID, BLOCK_ID]) { + expect(await service.resolve(placeId, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: true, + }); + } + }); + }); + + /* + * Owner decision, 2026-08-31: Colony Representative is a deputy over every colony, not a + * global administrator. It therefore resolves the way authority held above a place + * already resolves - access rights across the whole tree, and no admin anywhere. + */ + describe('a colony-wide deputy', () => { + it('gives a Colony Representative access rights but no admin at a colony', async () => { + givenAssignments([assignment(roleMap.ColonyRepresentative, null as unknown as number)]); + expect(await service.resolve(COLONY_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('gives the same result at a second, unrelated colony', async () => { + givenAssignments([assignment(roleMap.ColonyRepresentative, null as unknown as number)]); + expect(await service.resolve(OTHER_COLONY_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('gives access rights but no admin at a neighborhood under a colony', async () => { + givenAssignments([assignment(roleMap.ColonyRepresentative, null as unknown as number)]); + expect(await service.resolve(HOOD_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('gives access rights but no admin at a block under a colony', async () => { + givenAssignments([assignment(roleMap.ColonyRepresentative, null as unknown as number)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('never grants admin at any place on the tree', async () => { + givenAssignments([assignment(roleMap.ColonyRepresentative, COLONY_ID)]); + for (const placeId of [ + COLONY_ID, + OTHER_COLONY_ID, + HOOD_ID, + SIBLING_HOOD_ID, + BLOCK_ID, + SIBLING_BLOCK_ID, + OTHER_HOOD_BLOCK_ID, + ]) { + expect(await service.resolve(placeId, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + } + }); + + it('resolves the same whichever place the assignment names', async () => { + givenAssignments([assignment(roleMap.ColonyRepresentative, OTHER_COLONY_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('grants nothing at a place that is not on the geographic tree', async () => { + placeRepository.findById.mockResolvedValue({ id: 77, type: 'club' } as Place); + givenAssignments([assignment(roleMap.ColonyRepresentative, null as unknown as number)]); + expect(await service.resolve(77, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('grants nothing once the role is no longer held', async () => { + givenAssignments([assignment(roleMap.Concierge, null as unknown as number)]); + expect(await service.resolve(COLONY_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + }); + + describe('a colony-wide deputy who also holds a local office', () => { + it('adds the local block deputy\'s admin to the colony-wide access rights', async () => { + givenAssignments([ + assignment(roleMap.ColonyRepresentative, null as unknown as number), + assignment(roleMap.BlockDeputy, BLOCK_ID), + ]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: true, + }); + }); + + it('keeps the local block leader\'s full authority at their own block', async () => { + givenAssignments([ + assignment(roleMap.ColonyRepresentative, null as unknown as number), + assignment(roleMap.BlockLeader, BLOCK_ID), + ]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: true, + canManageAccess: true, + }); + }); + + it('does not carry the local office sideways to a sibling block', async () => { + givenAssignments([ + assignment(roleMap.ColonyRepresentative, null as unknown as number), + assignment(roleMap.BlockDeputy, BLOCK_ID), + ]); + expect(await service.resolve(SIBLING_BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: true, + }); + }); + + it('adds nothing at a place that is not on the geographic tree', async () => { + placeRepository.findById.mockResolvedValue({ id: 77, type: 'club' } as Place); + givenAssignments([ + assignment(roleMap.ColonyRepresentative, null as unknown as number), + assignment(roleMap.BlockDeputy, 77), + ]); + expect(await service.resolve(77, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + }); + + describe('malformed input fails closed', () => { + it('denies a place id that does not exist', async () => { + givenAssignments([assignment(roleMap.BlockLeader, BLOCK_ID)]); + expect(await service.resolve(9999, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies a place whose type is not on the geographic tree', async () => { + placeRepository.findById.mockResolvedValue({ id: 77, type: 'club' } as Place); + givenAssignments([assignment(roleMap.BlockLeader, 77)]); + expect(await service.resolve(77, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it.each([0, -1, NaN, 1.5])('denies place id %p', async (placeId: number) => { + givenAssignments([assignment(roleMap.Admin, COLONY_ID)]); + expect(await service.resolve(placeId, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it.each([0, -1, NaN, 1.5])('denies member id %p', async (memberId: number) => { + givenAssignments([assignment(roleMap.Admin, COLONY_ID)]); + expect(await service.resolve(BLOCK_ID, memberId)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('denies when the role map has not been populated yet', async () => { + roleRepository.roleMap = {}; + givenAssignments([assignment(roleMap.BlockLeader, BLOCK_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('stops the walk when a block is mislinked straight to a colony', async () => { + mapLocationRepository.findPlaceIdMapLocation.mockImplementation( + async (placeId: number) => + (placeId === BLOCK_ID + ? ({ place_id: BLOCK_ID, parent_place_id: COLONY_ID } as MapLocation) + : undefined) as MapLocation, + ); + givenAssignments([assignment(roleMap.ColonyLeader, COLONY_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + + it('stops the walk when the map locations form a cycle', async () => { + mapLocationRepository.findPlaceIdMapLocation.mockImplementation( + async (placeId: number) => + ({ + place_id: placeId, + parent_place_id: placeId === BLOCK_ID ? HOOD_ID : BLOCK_ID, + } as MapLocation), + ); + givenAssignments([assignment(roleMap.ColonyLeader, COLONY_ID)]); + expect(await service.resolve(BLOCK_ID, MEMBER_ID)).toEqual({ + canAdmin: false, + canManageAccess: false, + }); + }); + }); +}); diff --git a/api/src/services/place/place-capability.service.ts b/api/src/services/place/place-capability.service.ts new file mode 100644 index 00000000..51aedaea --- /dev/null +++ b/api/src/services/place/place-capability.service.ts @@ -0,0 +1,255 @@ +import { Service } from 'typedi'; + +import { + MapLocationRepository, + PlaceRepository, + RoleAssignmentRepository, + RoleRepository, +} from '../../repositories'; +import { Place, RoleAssignment } from '../../types/models'; + +/** + * The capabilities a member holds at one place. + * + * These are the two place capabilities classic Cybertown gated its controls with. The + * classic `place`/`neighbor`/`block` CGIs resolved the viewer against the place and set + * template variables from the result; `owneraccess` gated the Update button and the place + * wizard, and `rightsaccess` gated the Access Rights button. + */ +export interface PlaceCapabilities { + /** Classic `owneraccess`: administer this place. */ + canAdmin: boolean; + /** Classic `rightsaccess`: change who may do what at this place. */ + canManageAccess: boolean; +} + +/** The place types that sit on the colony -> hood -> block geographic tree. */ +export type ScopedPlaceType = 'colony' | 'hood' | 'block'; + +/** Each scoped type's parent type. A colony is the root, so it has none. */ +const PARENT_TYPE: Record = { + colony: null, + hood: 'colony', + block: 'hood', +}; + +/** The leader and deputy role names for each scoped place type. */ +const PLACE_ROLES: Record = { + colony: { leader: 'ColonyLeader', deputy: 'ColonyDeputy' }, + hood: { leader: 'NeighborhoodLeader', deputy: 'NeighborhoodDeputy' }, + block: { leader: 'BlockLeader', deputy: 'BlockDeputy' }, +}; + +/** + * Roles that carry both capabilities at every place, with no place scoping. These stand in + * for classic Cybertown's global grant, which was an access check against a single + * city-wide object rather than against the place being viewed. + */ +const GLOBAL_ADMIN_ROLES = ['Admin']; + +/** + * Roles that hold deputy authority over every colony at once. + * + * A member holding one of these is resolved the way the resolver already treats authority + * held above the place being acted on: the right to change access rights carries across the + * whole geographic tree, and nothing else does. So the role may open Access Rights at any + * colony, neighborhood or block, and may administer none of them. This reuses the existing + * inherited-rights shape rather than adding a second authority model, and it keeps the role + * clearly below a true global administrator. + */ +const GLOBAL_COLONY_DEPUTY_ROLES = ['ColonyRepresentative']; + +/** Depth guard, so malformed map_location rows cannot make the walk loop forever. */ +const MAX_ANCESTOR_DEPTH = 8; + +const isPositiveInteger = (value: number): boolean => + Number.isInteger(value) && value > 0; + +const isScopedPlaceType = (type: string): type is ScopedPlaceType => + type === 'colony' || type === 'hood' || type === 'block'; + +/** + * Resolves what a member may do at a place. + * + * This is the single authority answer for the geographic place tree: block, neighborhood + * and colony all resolve through here, so a capability the API advertises and the check + * the API enforces can never disagree. + * + * The rules are the ones classic Cybertown's `chDBCheckRights` implemented: + * + * - A place's own leader holds both capabilities at that place. + * - A place's own deputy administers the place but may not change its access rights. + * - Authority over an ancestor place carries `canManageAccess` down the tree, and nothing + * else. A colony leader could open a child neighborhood's Access Rights page but had no + * Update button there. + * - Nothing travels sideways to a sibling, or up from a child to its parent. + * - Everything else is denied. + * + * On top of those, two city-wide offices are resolved without a place: a global + * administrator holds both capabilities everywhere, and a colony-wide deputy holds the + * inherited capability everywhere on the tree. Each source is added to the result, so a + * member who holds a city office and a local office keeps the best of both. + */ +@Service() +export class PlaceCapabilityService { + constructor( + private mapLocationRepository: MapLocationRepository, + private placeRepository: PlaceRepository, + private roleAssignmentRepository: RoleAssignmentRepository, + private roleRepository: RoleRepository, + ) {} + + /** + * Resolves a member's capabilities at a single place. + * @param placeId id of the place being acted on + * @param memberId id of the member acting + * @returns the capabilities held; every capability is false unless proven otherwise + */ + public async resolve(placeId: number, memberId: number): Promise { + const denied: PlaceCapabilities = { canAdmin: false, canManageAccess: false }; + + if (!isPositiveInteger(placeId) || !isPositiveInteger(memberId)) { + return denied; + } + + const place = await this.placeRepository.findById(placeId); + if (!place || !place.type || !isScopedPlaceType(place.type)) { + return denied; + } + + const assignments = await this.roleAssignmentRepository.getByMemberId(memberId); + if (!assignments || assignments.length === 0) { + return denied; + } + + if (this.holdsAnyRoleOf(assignments, GLOBAL_ADMIN_ROLES)) { + return { canAdmin: true, canManageAccess: true }; + } + + const capabilities: PlaceCapabilities = { canAdmin: false, canManageAccess: false }; + + // A colony-wide deputy carries the inherited capability, and only that, to every place + // on the tree. Authority held at the place itself is added below, so a member who holds + // both sources ends up with the union of the two. + if (this.holdsAnyRoleOf(assignments, GLOBAL_COLONY_DEPUTY_ROLES)) { + capabilities.canManageAccess = true; + } + + const own = PLACE_ROLES[place.type]; + if (this.holdsRoleAt(assignments, own.leader, place.id)) { + capabilities.canAdmin = true; + capabilities.canManageAccess = true; + } else if (this.holdsRoleAt(assignments, own.deputy, place.id)) { + capabilities.canAdmin = true; + } + + // Only the right to change access rights is inherited, and only downwards. The walk is + // skipped when the member already holds it at the place itself. + if (!capabilities.canManageAccess) { + const ancestors = await this.getAncestors(place); + for (const ancestor of ancestors) { + const roles = PLACE_ROLES[ancestor.type as ScopedPlaceType]; + if ( + this.holdsRoleAt(assignments, roles.leader, ancestor.id) || + this.holdsRoleAt(assignments, roles.deputy, ancestor.id) + ) { + capabilities.canManageAccess = true; + break; + } + } + } + + return capabilities; + } + + /** + * Walks the geographic tree upwards from a place, nearest ancestor first. + * + * A step is only taken when the parent really is the type that belongs above the current + * place, so a mislinked map_location cannot invent an ancestor and widen authority. + * @param place the place to walk up from + * @returns the ancestor places, nearest first; empty for a colony + */ + private async getAncestors(place: Place): Promise { + const ancestors: Place[] = []; + const visited = new Set([place.id]); + let current = place; + + for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) { + const expectedType = PARENT_TYPE[current.type as ScopedPlaceType]; + if (!expectedType) { + break; + } + + const mapLocation = await this.mapLocationRepository.findPlaceIdMapLocation(current.id); + const parentId = mapLocation?.parent_place_id; + if (!isPositiveInteger(parentId) || visited.has(parentId)) { + break; + } + + const parent = await this.placeRepository.findById(parentId); + if (!parent || parent.type !== expectedType) { + break; + } + + visited.add(parent.id); + ancestors.push(parent); + current = parent; + } + + return ancestors; + } + + /** + * Reports whether the member holds a named role at one specific place. + * @param assignments the member's role assignments + * @param roleName role name as it appears in the role map, without spaces + * @param placeId the place the role must be held at + * @returns true only when a matching, place-scoped assignment exists + */ + private holdsRoleAt( + assignments: RoleAssignment[], + roleName: string, + placeId: number, + ): boolean { + const roleId = this.roleId(roleName); + if (!isPositiveInteger(roleId)) { + return false; + } + return assignments.some( + assignment => assignment.role_id === roleId && assignment.place_id === placeId, + ); + } + + /** + * Reports whether the member holds any of the named roles, at any place or at none. + * + * The place an assignment names is deliberately ignored here. These roles are city-wide + * offices, and the admin panel stores them with no place at all. + * @param assignments the member's role assignments + * @param roleNames role names as they appear in the role map, without spaces + * @returns true when at least one of the named roles is held + */ + private holdsAnyRoleOf(assignments: RoleAssignment[], roleNames: string[]): boolean { + const roleIds = roleNames + .map(name => this.roleId(name)) + .filter(isPositiveInteger); + if (roleIds.length === 0) { + return false; + } + return assignments.some(assignment => roleIds.includes(assignment.role_id)); + } + + /** + * Looks a role name up in the role map. + * + * The role map is filled from the database after construction, so a lookup before it is + * ready must resolve to nothing rather than throw. An unknown role is then denied by + * `holdsRoleAt` and `holdsAnyRoleOf`, which is the safe direction. + * @param roleName role name as it appears in the role map, without spaces + * @returns the role id, or undefined when the name is not mapped + */ + private roleId(roleName: string): number | undefined { + return this.roleRepository.roleMap?.[roleName]; + } +}