diff --git a/.gitignore b/.gitignore index 7201b99..3cc2d11 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ config/token.json config/jobs.json config/jobs copy.json config/userPrefs.json +config/authorized.json uploads/* !uploads/.gitkeep diff --git a/README.md b/README.md index f617524..0ae892c 100644 --- a/README.md +++ b/README.md @@ -66,23 +66,34 @@ Say you want to add a bot called `alerts`. ``` Setting `enabled: false` will keep the bot listed but return 404 for all `/CollabCentral/alerts/*` routes. -3. **Bot metadata + authorized users** — edit `config/config.json` and add - an entry under `webex.bot`: +3. **Bot metadata** — edit `config/config.json` and add a structural entry + under `webex.bot`: ```json - "alerts": { - "label": "Ops Alerts", - "authorized": { - "": { - "id": "", - "displayName": "…", - "email": "…", - "avatar": "…", - "groups": [ { "name": "…", "alias": "…", "id": "" } ] + "alerts": { "label": "Ops Alerts" } + ``` +4. **Authorized users** — add per-user data to `config/authorized.json` + (gitignored; created automatically at first admin action if it doesn't + exist): + ```json + { + "admins": [""], + "bot": { + "alerts": { + "": { + "id": "", + "displayName": "…", + "email": "…", + "avatar": "…", + "groups": [ { "name": "…", "alias": "…", "id": "" } ] + } } } } ``` - Only person IDs listed under `authorized` can log into that bot's UI. + Only person IDs listed under `bot.` can sign into that bot's UI. + Anyone listed under `admins` (any personId) can manage authorized users + across every bot via the in-app Admin page — much easier than hand- + editing this file, but the file is the source of truth. 4. **Icons** — drop `alerts.png` and `alerts.ico` into `html/` (they're served by the per-bot static mount). 5. **OAuth redirect URI** — in the Webex integration used for OAuth, register @@ -108,8 +119,8 @@ docker run -d \ ``` The `config/` mount should contain at minimum `config.json`, `botTokens.json`, -`token.json`, `languages.json`, and (if you want to preserve state) -`jobs.json` and `userPrefs.json`. +`token.json`, `languages.json`, `authorized.json`, and (if you want to +preserve state) `jobs.json` and `userPrefs.json`. ## File layout @@ -119,8 +130,8 @@ Committed to the repo: and the send queue. - `html/` — frontend (sendMessage / monitorJobs / jobDetail pages, per-bot icons, shared JS libraries). -- `config/config.json` — server settings, per-bot labels, and the - authorized-user table for each bot. +- `config/config.json` — structural: server settings, per-bot labels, + integration ids. No per-user data lives here anymore. - `config/languages.json` — supported translation languages. - `config/botTokens.example.json` — template for `botTokens.json`. - `.env.example` — template for `.env`. @@ -133,6 +144,9 @@ Runtime state (gitignored, not committed): - `config/botTokens.json` — per-bot access tokens. - `config/token.json` — service-account OAuth tokens (rotated by the refresh cron). +- `config/authorized.json` — admins list + per-bot authorized users + (with their favorite groups). Edited at runtime by the admin page + and the favorites picker on the compose page. - `config/jobs.json` — building / running / scheduled / completed jobs. - `config/userPrefs.json` — per-user preferences (language, etc.). - `uploads/` — CSVs of recipient IDs and any attached images. diff --git a/config/config.json b/config/config.json index 4f1303e..6638fc7 100644 --- a/config/config.json +++ b/config/config.json @@ -3,273 +3,14 @@ "name": "CollabCentral", "port": "1451" }, - "admins": [ - "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU" - ], "languages": [], "webex": { "bot": { "novi": { - "label": "Novi Communicator", - "authorized": { - "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU": { - "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU", - "displayName": "Joe McQueen", - "email": "mcqueenj@ae.com", - "avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~667e3673b56f4ddc39139c73da9140dd631eb821a756524ead12e37e3a61afc7~9c5b6e767cff46e3af35a003cea38f25~1600", - "groups": [ - { - "name": "Group: Audio Visual", - "alias": "AV Team", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZTQ3NmY3MjktZWViYi00MDI3LWFlMTctNzc1YWI1ZDgzYmNhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: South Side Campus", - "alias": "SSW Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: IT Warrendale", - "alias": "Warrendale", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: New York Design Office Personnel", - "alias": "NY Design Center", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: 34th Street Office Personnel", - "alias": "34th Street", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: San Francisco Personnel", - "alias": "San Francisco Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Ottawa Campus", - "alias": "Ottawa Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hazleton Campus", - "alias": "Hazleton Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Canada Distribution Center", - "alias": "Canada DC", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Mexico Office Personnel", - "alias": "Mexico Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hong Kong Office", - "alias": "Hong Kong Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hazleton Mgmt", - "alias": "Hazleton Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Ottawa Campus Mgmt", - "alias": "Ottawa Campus Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: MDC DC Mgmt", - "alias": "Canada DC Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - } - ] - }, - "Y2lzY29zcGFyazovL3VzL1BFT1BMRS84MWUzMzExMi1jYWZlLTQ0NWYtOGE5OS01MDI3Mzg4ZDQwNGM": { - "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS84MWUzMzExMi1jYWZlLTQ0NWYtOGE5OS01MDI3Mzg4ZDQwNGM", - "displayName": "Megan Reinecker", - "email": "reineckerm@ae.com", - "avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~81e33112-cafe-445f-8a99-5027388d404c~c5234572b50e49ff884c45b3ea0e3fea~1600", - "groups": [ - { - "name": "Group: South Side Campus", - "alias": "SSW Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: IT Warrendale", - "alias": "Warrendale", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: New York Design Office Personnel", - "alias": "NY Design Center", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: 34th Street Office Personnel", - "alias": "34th Street", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: San Francisco Personnel", - "alias": "San Francisco Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Ottawa Campus", - "alias": "Ottawa Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hazleton Campus", - "alias": "Hazleton Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Canada Distribution Center", - "alias": "Canada DC", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Mexico Office Personnel", - "alias": "Mexico Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hong Kong Office", - "alias": "Hong Kong Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hazleton Mgmt", - "alias": "Hazleton Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Ottawa Campus Mgmt", - "alias": "Ottawa Campus Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: MDC DC Mgmt", - "alias": "Canada DC Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - } - ] - }, - "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NmZkYWEzYy03YTNkLTRjY2QtYWU4Ny0xYzIzNmIyNGE0Zjg": { - "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NmZkYWEzYy03YTNkLTRjY2QtYWU4Ny0xYzIzNmIyNGE0Zjg", - "displayName": "Jayna Catalina", - "email": "catalinaj@ae.com", - "avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~cf803b9485e536eece6e2f2e541f8ab8691867a0765656a0ebb28ba4b74d7bca~8afff234e4f045ad9fa92af6f8d082c0~1600", - "groups": [ - { - "name": "Group: South Side Campus", - "alias": "SSW Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYjAxOWM5MzgtNTZhNi00NTQxLTk0NjgtYjFmOWEwM2I5NTNlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: IT Warrendale", - "alias": "Warrendale", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjkwN2RlZGUtZjJmNi00OTdjLTkwMjItZTRmOGIzNzM3MTFhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: New York Design Office Personnel", - "alias": "NY Design Center", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjUzOGQzYmYtY2UzOS00Y2ZlLWFmZWUtNGViYTM0OGQ3Y2FjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: 34th Street Office Personnel", - "alias": "34th Street", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZGVmOTA0MDMtMTBlZS00YzRlLWIzYWItYzNjZDlkODhmMTQ1OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: San Francisco Personnel", - "alias": "San Francisco Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjJjYTcxYTktYWYzYy00YjQ4LWIxZjUtMjc1NDEwNGYwMWJlOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Ottawa Campus", - "alias": "Ottawa Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZWIyYWI1ZDgtYzk2Ny00MmUwLTg0OWQtZGE5OTYzMDdkNDhhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hazleton Campus", - "alias": "Hazleton Campus", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMDc3NTI1NGEtZTIxMS00ZTQ1LTgxNjktNjYxYTAzOGYxNTE3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Canada Distribution Center", - "alias": "Canada DC", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZmUwMDk1MjUtMzEyZC00YzMxLTgwMzItNWEyODIwODRiYTVjOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Mexico Office Personnel", - "alias": "Mexico Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvNjhmYTJiZGUtNjI2My00NWEwLTljMDMtNjMyMGI0NmVlODE0OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hong Kong Office", - "alias": "Hong Kong Office", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvMjdlM2QzZGEtZjI4Ny00NDg3LWI3NzUtM2JmZTA3MzNhNzZhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Hazleton Mgmt", - "alias": "Hazleton Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvYTEzODI3OTgtOWY5Zi00YmY1LTlkMmQtODNhZTk2OGY0MjU3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: Ottawa Campus Mgmt", - "alias": "Ottawa Campus Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZDYwNzFhYmMtYzNjZC00ZDdkLWFiYTktOGY0NDRiZTViODQ3OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - }, - { - "name": "Group: MDC DC Mgmt", - "alias": "Canada DC Mgmt", - "id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvOTk2ZWRmNzYtNjdlYS00MjdkLWEzNGEtMTBjNjJhYmRkMDM5OmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA" - } - ] - } - } + "label": "Novi Communicator" }, "techupdates": { - "label": "Technology Updates", - "authorized": { - "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU": { - "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU", - "displayName": "Joe McQueen", - "email": "mcqueenj@ae.com", - "avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~667e3673b56f4ddc39139c73da9140dd631eb821a756524ead12e37e3a61afc7~9c5b6e767cff46e3af35a003cea38f25~1600", - "groups": [] - }, - "Y2lzY29zcGFyazovL3VzL1BFT1BMRS83NmZkODZjNi1iNGEyLTRmYWItODE2OC00NGY0N2FjMmYxYzA": { - "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS83NmZkODZjNi1iNGEyLTRmYWItODE2OC00NGY0N2FjMmYxYzA", - "displayName": "Samantha Matthews", - "email": "MatthewsS@AE.com", - "avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~2c5040ed2e348bc8fff87e924a089f603d31014677e744b988f03824d9bf2ce7~20b84d9e14044642bb28e21abd421b41~1600", - "groups": [] - }, - "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hNTBjNDgxMC01MjQ5LTQ3ZjQtOGMwZi05NmFhY2NkYTA4NGQ": { - "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hNTBjNDgxMC01MjQ5LTQ3ZjQtOGMwZi05NmFhY2NkYTA4NGQ", - "displayName": "Rachel Grattan", - "email": "grattanr@ae.com", - "avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~b0a8973dc9d4e6b69519ee3a32c7955b27faac48ae7b3741d25a6db269313846~f50bb3274e3e427a802bfd27c3454016~1600", - "groups": [] - }, - "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9iOTY4NzY5Ni1kNjc5LTRmMjUtYWYzZS0wZTQzMWQ5NWU1ZTA": { - "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9iOTY4NzY5Ni1kNjc5LTRmMjUtYWYzZS0wZTQzMWQ5NWU1ZTA", - "displayName": "Chloe Piccola", - "email": "piccolac@ae.com", - "avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~5cbf8b8daff9d364e6004f7d6b3b3f6b7bc48f3770b7c91d17b0399f47701680~9b386ca401d3460aab619f0afd5a5151~1600", - "groups": [] - } - } + "label": "Technology Updates" } }, "integration": { @@ -280,4 +21,4 @@ "appName": "CollabCentral" } } -} \ No newline at end of file +} diff --git a/index.js b/index.js index e85458c..6302519 100644 --- a/index.js +++ b/index.js @@ -18,9 +18,42 @@ import * as helpers from './lib/helpers.js'; const queue = new PQueue({ concurrency: 10 }); //Load the config files from storage. +// config.json = committed, structural (server port, bot labels, integration ids). +// authorized.json = gitignored, mutable per-user data: +// { admins: [personId...], bot: { : { : { ...profile, groups: [...] } } } } +// jobs.json = gitignored, runtime queue state. +// userPrefs.json = gitignored, per-recipient preferences (language, ...). var config = JSON.parse(fs.readFileSync('./config/config.json')); var jobs = JSON.parse(fs.readFileSync('./config/jobs.json')); var userPrefs = JSON.parse(fs.readFileSync('./config/userPrefs.json')); +var authorized = loadAuthorized(); + +function loadAuthorized() { + try { + var doc = JSON.parse(fs.readFileSync('./config/authorized.json')); + doc.admins = Array.isArray(doc.admins) ? doc.admins : []; + doc.bot = (doc.bot && typeof doc.bot === 'object') ? doc.bot : {}; + return doc; + } catch (err) { + // Missing file (fresh deploy) is fine — we start empty and the admin + // page can populate. Any other parse error is fatal because otherwise + // the whole authorization layer silently permits nothing. + if (err && err.code === 'ENOENT') { + console.log('loadAuthorized: authorized.json not found, starting empty.'); + return { admins: [], bot: {} }; + } + throw err; + } +} + +// Returns the mutable per-user record for (appName, personId), creating the +// bot bucket if this is the first write. Never returns null when personId is +// truthy — callers use this to add favorites / users without null-checking. +// Read-only callers should prefer helpers.getAuthorizedEntry(authorized, ...). +function getOrCreateAuthorizedEntry(appName, personId) { + if (!authorized.bot[appName]) authorized.bot[appName] = {}; + return authorized.bot[appName][personId] || null; +} // Defensive init so code below can safely `.push`, `.filter`, and index into // these regardless of what the on-disk jobs.json happens to contain. @@ -496,7 +529,8 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => { if (req.params.scope == "groups") { if (req.params.action == "list") { logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/list"); - res.status(200).send(config.webex.bot[req.params.app].authorized[req.cookies.id].groups) + var listEntry = helpers.getAuthorizedEntry(authorized, req.params.app, req.cookies.id); + res.status(200).send((listEntry && listEntry.groups) || []); } else if (req.params.action == "find") { logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find"); getGroupsFromCache() @@ -507,21 +541,21 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => { res.status(502).send('Failed to load groups from Webex.'); }); } - } else { res.status(404) } - } else { res.status(401) } + } else { res.status(404).send('Not found.'); } + } else { res.status(401).send('Unauthorized.'); } }) // Add / remove a favorite group for the calling user. Favorites live inside -// config.json under `webex.bot[app].authorized[personId].groups` so they -// survive restarts and are picklable in the compose page's Favorite Groups -// selector. Both endpoints validate that: +// authorized.json under `bot[app][personId].groups` so they survive restarts +// and are picklable in the compose page's Favorite Groups selector. Both +// endpoints validate that: // - the caller is authorized for :app // - :id looks like a Webex SCIM group id we know about (present in the // cached org-wide group list) — this stops a bad actor from stuffing // arbitrary strings into the config. // Because index.js is single-process and node is single-threaded, concurrent -// requests here can't corrupt config.json — every read/mutate/write executes -// atomically within one handler. +// requests here can't corrupt authorized.json — every read/mutate/write +// executes atomically within one handler. app.post('/CollabCentral/:app/user/groups/add', async function (req, res) { if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.'); var appName = req.params.app; @@ -540,14 +574,18 @@ app.post('/CollabCentral/:app/user/groups/add', async function (req, res) { } if (!group) return res.status(404).send('Unknown group id.'); - var favorites = config.webex.bot[appName].authorized[personId].groups || []; + var entry = helpers.getAuthorizedEntry(authorized, appName, personId); + // isAuthorized guarantees the entry exists but be defensive; a race + // between an admin removal and this request could otherwise crash. + if (!entry) return res.status(401).send('Unauthorized.'); + var favorites = Array.isArray(entry.groups) ? entry.groups : []; if (favorites.find(function (g) { return g.id === groupId; })) { return res.status(200).send(favorites); } favorites.push({ name: group.displayName, id: group.id }); - config.webex.bot[appName].authorized[personId].groups = favorites; + entry.groups = favorites; try { - saveConfig(config, './config/config.json'); + saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'groups/add save failed: ' + (err && err.message || err)); @@ -565,15 +603,17 @@ app.post('/CollabCentral/:app/user/groups/remove', function (req, res) { var groupId = req.body && req.body.id; if (!groupId) return res.status(400).send('Missing group id.'); - var favorites = config.webex.bot[appName].authorized[personId].groups || []; + var entry = helpers.getAuthorizedEntry(authorized, appName, personId); + if (!entry) return res.status(401).send('Unauthorized.'); + var favorites = Array.isArray(entry.groups) ? entry.groups : []; var before = favorites.length; var removed = favorites.find(function (g) { return g.id === groupId; }); favorites = favorites.filter(function (g) { return g.id !== groupId; }); if (favorites.length === before) return res.status(200).send(favorites); - config.webex.bot[appName].authorized[personId].groups = favorites; + entry.groups = favorites; try { - saveConfig(config, './config/config.json'); + saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'groups/remove save failed: ' + (err && err.message || err)); @@ -586,13 +626,13 @@ app.post('/CollabCentral/:app/user/groups/remove', function (req, res) { // ---- Admin: manage the authorized-user list for a bot --------------------- // -// Admin authority lives in a top-level config.admins array (personIds). -// Every admin route rejects with 403 for non-admin callers — 403 rather than -// 401 so the client can tell an admin-only route apart from a signed-out -// state (which would 401). Admin actions are cross-bot in concept but the -// routes are still :app-scoped because the resource being edited is per-bot -// (authorized users on THAT bot) and it keeps the OAuth session model -// unchanged (admin uses the same session cookie as any other page). +// Admin authority lives in authorized.admins (personIds). Every admin route +// rejects with 403 for non-admin callers — 403 rather than 401 so the client +// can tell an admin-only route apart from a signed-out state (which would +// 401). Admin actions are cross-bot in concept but the routes are still +// :app-scoped because the resource being edited is per-bot (authorized users +// on THAT bot) and it keeps the OAuth session model unchanged (admin uses +// the same session cookie as any other page). // // requireAdmin returns null on success or an Express-response-sending // function on failure, so each handler stays a straight-line function. @@ -617,8 +657,8 @@ function adminUserRow(entry) { } function adminUsersList(appName) { - var authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {}; - return Object.keys(authorized).map(function (id) { return adminUserRow(authorized[id]); }); + var bucket = (authorized.bot && authorized.bot[appName]) || {}; + return Object.keys(bucket).map(function (id) { return adminUserRow(bucket[id]); }); } app.get('/CollabCentral/:app/admin/users', function (req, res) { @@ -643,9 +683,10 @@ app.post('/CollabCentral/:app/admin/users', async function (req, res) { } if (!person) return res.status(404).send('No Webex user found for that email.'); - var authorized = config.webex.bot[appName].authorized = config.webex.bot[appName].authorized || {}; - if (!authorized[person.id]) { - authorized[person.id] = { + if (!authorized.bot[appName]) authorized.bot[appName] = {}; + var bucket = authorized.bot[appName]; + if (!bucket[person.id]) { + bucket[person.id] = { id: person.id, displayName: person.displayName, email: person.email, @@ -653,7 +694,7 @@ app.post('/CollabCentral/:app/admin/users', async function (req, res) { groups: [] }; try { - saveConfig(config, './config/config.json'); + saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'admin/users save failed: ' + (err && err.message || err)); @@ -663,7 +704,7 @@ app.post('/CollabCentral/:app/admin/users', async function (req, res) { 'admin/users + "' + person.displayName + '" <' + person.email + '>'); } res.status(200).send({ - user: adminUserRow(authorized[person.id]), + user: adminUserRow(bucket[person.id]), users: adminUsersList(appName) }); }); @@ -672,14 +713,14 @@ app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) { if (requireAdmin(req, res)) return; var appName = req.params.app; var targetId = req.params.id; - var authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {}; - if (!authorized[targetId]) { + var bucket = (authorized.bot && authorized.bot[appName]) || {}; + if (!bucket[targetId]) { return res.status(200).send({ removed: false, users: adminUsersList(appName) }); } - var name = authorized[targetId].displayName || targetId; - delete authorized[targetId]; + var name = bucket[targetId].displayName || targetId; + delete bucket[targetId]; try { - saveConfig(config, './config/config.json'); + saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'admin/users delete save failed: ' + (err && err.message || err)); @@ -1617,11 +1658,11 @@ async function cleanCompletedJobs(jobs) { function isAuthorized(appName, personId) { logger("isAuthorized", appName + " " + personId) - return helpers.isAuthorized(config, botTokens, appName, personId); + return helpers.isAuthorized(config, botTokens, authorized, appName, personId); } function isAdmin(personId) { - return helpers.isAdmin(config, personId); + return helpers.isAdmin(authorized, personId); } function msToTime(duration) { diff --git a/lib/helpers.js b/lib/helpers.js index 384fffa..a293226 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -50,22 +50,33 @@ export function getBotConfig(config, botTokens, appName) { return cfg; } -// True iff `personId` is listed under the bot's `authorized` map AND the bot -// is enabled. Missing personId or missing bot config yields false. -export function isAuthorized(config, botTokens, appName, personId) { - var botCfg = getBotConfig(config, botTokens, appName); - if (!botCfg) return false; - if (!personId) return false; - return !!(botCfg.authorized && botCfg.authorized[personId]); +// Returns the "per-user record" for (appName, personId) if one exists in the +// authorized doc, otherwise null. Kept here as a single lookup point so +// callers never reach into authorized.bot[...] directly and every consumer +// (isAuthorized, favorites, admin routes) shares one nullability contract. +export function getAuthorizedEntry(authorized, appName, personId) { + if (!authorized || !authorized.bot || !appName || !personId) return null; + var bucket = authorized.bot[appName]; + return (bucket && bucket[personId]) || null; } -// True iff `personId` appears in the top-level config.admins array. Admin -// authority is intentionally cross-bot: a single admin manages authorization -// for every bot the process serves. Missing personId or missing admins list -// yields false so an unconfigured deployment fails closed. -export function isAdmin(config, personId) { +// True iff `personId` is authorized on the bot AND the bot is enabled. The +// three-argument shape (config, tokens, authorized, app, id) lets the same +// pure function serve both server code and unit tests without mocking file +// I/O. Missing config, tokens, or authorized yields false so a partially- +// loaded process fails closed rather than granting access. +export function isAuthorized(config, botTokens, authorized, appName, personId) { + if (!getBotConfig(config, botTokens, appName)) return false; + return getAuthorizedEntry(authorized, appName, personId) !== null; +} + +// True iff `personId` appears in authorized.admins. Admin authority is +// intentionally cross-bot: one admin manages authorization for every bot +// the process serves. Missing personId or missing admins list yields false +// so an unconfigured deployment fails closed. +export function isAdmin(authorized, personId) { if (!personId) return false; - var admins = config && config.admins; + var admins = authorized && authorized.admins; if (!Array.isArray(admins)) return false; return admins.indexOf(personId) !== -1; } diff --git a/test/helpers.test.js b/test/helpers.test.js index ce3166b..44b0c61 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -6,6 +6,7 @@ import { getBotToken, isBotEnabled, getBotConfig, + getAuthorizedEntry, isAuthorized, isAdmin, getOAuthRedirectUri, @@ -15,30 +16,19 @@ import { COMPLETED_RETENTION_DAYS, } from '../lib/helpers.js'; -// A minimal, self-contained config/tokens pair used across the auth tests so -// each test doesn't have to reconstruct one. Keeping novi enabled + techupdates -// disabled + noman unknown covers the three interesting bot states. +// A minimal, self-contained state triple used across the auth tests so each +// test doesn't have to reconstruct one. Keeping novi enabled + techupdates +// disabled + orphan-with-no-users covers the interesting bot states, and +// splitting per-user data into an authorized doc mirrors the runtime split +// between committed config.json and gitignored authorized.json. function makeState() { return { config: { webex: { bot: { - novi: { - label: 'Novi Communicator', - authorized: { - 'personA': { id: 'personA' }, - 'personB': { id: 'personB' }, - }, - }, - techupdates: { - label: 'Tech Updates', - authorized: { - 'personA': { id: 'personA' }, - }, - }, - orphan: { - label: 'Configured but no authorized users', - }, + novi: { label: 'Novi Communicator' }, + techupdates: { label: 'Tech Updates' }, + orphan: { label: 'Configured but no authorized users' }, }, }, }, @@ -48,6 +38,13 @@ function makeState() { orphan: { token: 'orphan-token', enabled: true }, flatstring: 'flat-token', }, + authorized: { + admins: [], + bot: { + novi: { 'personA': { id: 'personA' }, 'personB': { id: 'personB' } }, + techupdates: { 'personA': { id: 'personA' } }, + }, + }, }; } @@ -165,38 +162,65 @@ describe('getBotConfig', () => { }); }); +describe('getAuthorizedEntry', () => { + const { authorized } = makeState(); + + test('returns the per-user record when present', () => { + assert.deepEqual(getAuthorizedEntry(authorized, 'novi', 'personA'), { id: 'personA' }); + }); + + test('returns null for an unknown person on a known bot', () => { + assert.equal(getAuthorizedEntry(authorized, 'novi', 'stranger'), null); + }); + + test('returns null for a bot with no bucket', () => { + assert.equal(getAuthorizedEntry(authorized, 'orphan', 'personA'), null); + }); + + test('null on missing pieces', () => { + assert.equal(getAuthorizedEntry(null, 'novi', 'personA'), null); + assert.equal(getAuthorizedEntry({}, 'novi', 'personA'), null); + assert.equal(getAuthorizedEntry(authorized, '', 'personA'), null); + assert.equal(getAuthorizedEntry(authorized, 'novi', ''), null); + }); +}); + describe('isAuthorized', () => { - const { config, botTokens } = makeState(); + const { config, botTokens, authorized } = makeState(); test('true when the person is listed under the bot and the bot is enabled', () => { - assert.equal(isAuthorized(config, botTokens, 'novi', 'personA'), true); + assert.equal(isAuthorized(config, botTokens, authorized, 'novi', 'personA'), true); }); test('false when the person is not listed under this bot', () => { - assert.equal(isAuthorized(config, botTokens, 'novi', 'stranger'), false); + assert.equal(isAuthorized(config, botTokens, authorized, 'novi', 'stranger'), false); }); test('false when the bot is disabled, even for a listed person', () => { - assert.equal(isAuthorized(config, botTokens, 'techupdates', 'personA'), false); + assert.equal(isAuthorized(config, botTokens, authorized, 'techupdates', 'personA'), false); }); test('false when personId is falsy', () => { - assert.equal(isAuthorized(config, botTokens, 'novi', undefined), false); - assert.equal(isAuthorized(config, botTokens, 'novi', ''), false); + assert.equal(isAuthorized(config, botTokens, authorized, 'novi', undefined), false); + assert.equal(isAuthorized(config, botTokens, authorized, 'novi', ''), false); }); - test('false when the bot config has no authorized block', () => { - assert.equal(isAuthorized(config, botTokens, 'orphan', 'personA'), false); + test('false when the bot config has no authorized bucket', () => { + assert.equal(isAuthorized(config, botTokens, authorized, 'orphan', 'personA'), false); + }); + + test('false when authorized doc is missing entirely', () => { + assert.equal(isAuthorized(config, botTokens, null, 'novi', 'personA'), false); }); }); describe('isAdmin', () => { - test('true when the person appears in config.admins', () => { - assert.equal(isAdmin({ admins: ['personA', 'personB'] }, 'personA'), true); + test('true when the person appears in authorized.admins', () => { + assert.equal(isAdmin({ admins: ['personA', 'personB'], bot: {} }, 'personA'), true); }); - test('false when the person is not in config.admins', () => { - assert.equal(isAdmin({ admins: ['personA'] }, 'stranger'), false); + test('false when the person is not in authorized.admins', () => { + assert.equal(isAdmin({ admins: ['personA'], bot: {} }, 'stranger'), false); }); test('false when personId is missing', () => { @@ -210,7 +234,7 @@ describe('isAdmin', () => { assert.equal(isAdmin({ admins: 'personA' }, 'personA'), false); }); - test('false on missing config entirely', () => { + test('false on missing authorized doc entirely', () => { assert.equal(isAdmin(undefined, 'personA'), false); assert.equal(isAdmin(null, 'personA'), false); });