From 441454469e5164e4cafe3e22c6040e8b9c2da1e4 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Thu, 8 Oct 2026 10:42:25 +1100 Subject: [PATCH] fix(concierge): admit only admin and support users until access groups are set The shared route guard let everyone in when `app.allow_access_groups` was unset, and concierge ships with it unset, so any signed-in staff member got the front desk: everyone's desk and parking bookings, the visitor list, catering orders, the staff directory and the reports. The guard now takes default groups from the app's `PLACEOS_APP_ACCESS` provider and uses them only while no groups are configured. Concierge declares `placeos_admin` and `placeos_support`, which every admin and support user carries, so an unconfigured deployment keeps working for the people who set it up and refuses plain staff. A configured list replaces the defaults, and the other apps provide nothing and behave as before. --- apps/concierge/src/app/app.config.ts | 11 +++++++ docs/settings/concierge.md | 1 + .../src/lib/authorised-user.guard.ts | 14 +++++++-- .../src/tests/authorised-user.guard.spec.ts | 30 ++++++++++++++++++- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/concierge/src/app/app.config.ts b/apps/concierge/src/app/app.config.ts index a0686ee4608..d4272dd654a 100644 --- a/apps/concierge/src/app/app.config.ts +++ b/apps/concierge/src/app/app.config.ts @@ -30,6 +30,8 @@ import { routes } from './app.routes'; import * as Sentry from '@sentry/angular'; +import { PLACEOS_APP_ACCESS } from '@placeos/components'; + import { LocaleService, registerActiveLocale, @@ -74,6 +76,15 @@ export const appConfig: ApplicationConfig = { useFactory: (localeService: LocaleService) => localeService.locale, }, { provide: TitleStrategy, useClass: SettingsTitleStrategy }, + { + provide: PLACEOS_APP_ACCESS, + useValue: { + // Concierge shows everyone's bookings, visitors and the staff + // directory, so without `app.allow_access_groups` it is for + // admin and support users only. + default_groups: ['placeos_admin', 'placeos_support'], + }, + }, provideHttpClient(withXhr(), withInterceptorsFromDi()), ], }; diff --git a/docs/settings/concierge.md b/docs/settings/concierge.md index 09406b293dd..6dbeff1d2bb 100644 --- a/docs/settings/concierge.md +++ b/docs/settings/concierge.md @@ -57,6 +57,7 @@ Each locale entry requires an `id` and `name`: | `features` | string[] | All features enabled (see below) | List of features enabled in the app. Available values: `spaces`, `zones`, `desks`, `parking`, `visitors`, `assets`, `facilities`, `internal-users`, `points`, `surveys`, `catering`, `events`, `booking-report`, `attendance-report`, `contact-tracing-report`, `signage`, `email-templates`. | | `feature_groups` | object | – | Maps feature names to the user groups allowed to access them, e.g. `parking-requests`. Features without an entry are available to all users. | | `admin_group` | string | `"concierge_admins"` | Name of the user group that grants access to admin sections of the app. Members of `placeos_admin` and `placeos_support` are always allowed. | +| `allow_access_groups` | string[] | – | User groups allowed to open the app at all. When unset, only members of `placeos_admin` and `placeos_support` (admin and support users) are admitted; set it to open the app to a front-desk group. | | `delegated` | boolean | `false` | Set when your PlaceOS instance uses delegated calendar access. Hides event actions from the event details view. | | `has_uploads` | boolean | `true` | Whether file upload support (S3/cloud storage) is available in the app. | | `bulk_actions` | boolean | `false` | Let staff select several rows and apply one action to all of them. Desk, parking and locker bookings, parking requests and asset requests get approve and reject actions. Catering orders get a status action. | diff --git a/libs/components/src/lib/authorised-user.guard.ts b/libs/components/src/lib/authorised-user.guard.ts index 4b9453079a7..ab569129aab 100644 --- a/libs/components/src/lib/authorised-user.guard.ts +++ b/libs/components/src/lib/authorised-user.guard.ts @@ -56,7 +56,13 @@ function resolvedWithin(promise: Promise, delay: number) { } export abstract class PLACEOS_APP_ACCESS { - public readonly group: string; + /** A single group that gates the app, regardless of settings. */ + public readonly group?: string; + /** + * Groups that gate the app while `app.allow_access_groups` is not set. + * Without them an unconfigured app admits every signed-in user. + */ + public readonly default_groups?: string[]; } @Injectable({ @@ -98,9 +104,13 @@ export class AuthorisedUserGuard { ]), ); if (!state_ready) return this.offlineAccess(); + const configured: string[] = + this._settings.get('app.allow_access_groups') || []; const groups = this._access?.group ? [this._access.group] - : this._settings.get('app.allow_access_groups') || []; + : configured.length + ? configured + : this._access?.default_groups || []; const use_group_subsystem_access = await this.useGroupSubsystemAccess(); let can_activate = false; if (use_group_subsystem_access) { diff --git a/libs/components/src/tests/authorised-user.guard.spec.ts b/libs/components/src/tests/authorised-user.guard.spec.ts index efa938cacbf..25d602697b9 100644 --- a/libs/components/src/tests/authorised-user.guard.spec.ts +++ b/libs/components/src/tests/authorised-user.guard.spec.ts @@ -25,7 +25,9 @@ import { describe('AuthorisedUserGuard', () => { let spectator: SpectatorService; - const access_mock = { group: '' }; + const access_mock: { group: string; default_groups?: string[] } = { + group: '', + }; const settings_mock = { app_name: 'workplace', get: vi.fn((key: string): any => []), @@ -54,6 +56,7 @@ describe('AuthorisedUserGuard', () => { beforeEach(() => { vi.clearAllMocks(); access_mock.group = ''; + delete access_mock.default_groups; settings_mock.app_name = 'workplace'; settings_mock.get.mockImplementation((key) => key === 'app.allow_access_groups' ? [] : undefined, @@ -184,6 +187,31 @@ describe('AuthorisedUserGuard', () => { access_mock.group = ''; }); + it('should fall back to the app default groups when none are configured', async () => { + access_mock.default_groups = ['placeos_admin', 'placeos_support']; + const router = spectator.inject(Router); + + setCurrentUser({ groups: [] } as any); + await expect(spectator.service.canActivate()).resolves.toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/unauthorised']); + + setCurrentUser({ groups: ['placeos_support'] } as any); + await expect(spectator.service.canActivate()).resolves.toBe(true); + }); + + it('should prefer configured access groups over the app default groups', async () => { + access_mock.default_groups = ['placeos_admin']; + settings_mock.get.mockImplementation((key) => + key === 'app.allow_access_groups' ? ['front_desk'] : undefined, + ); + + setCurrentUser({ groups: ['front_desk'] } as any); + await expect(spectator.service.canActivate()).resolves.toBe(true); + + setCurrentUser({ groups: ['placeos_admin'] } as any); + await expect(spectator.service.canActivate()).resolves.toBe(false); + }); + it('waits for user groups and app settings to load', async () => { common_lib.user_groups_loaded.set(false); let resolve_settings: () => void;