diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a039390b..3dfdf717c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,27 @@ git clone https://github.com/GMOD/Apollo3 You'll need `yarn` to be installed. +## Node version compatibility + +Apollo3 test tooling currently expects Node 20 or 22. + +- Node 26 is known to break Jest 29 in this repository with errors such as + `TypeError: require.resolve.paths is not a function`. +- Run `yarn check:node` before running tests. + +If you use `nvm`, you can pin a compatible runtime with: + +```sh +nvm use +``` + +If you do not use a Node version manager, you can still run commands with Node +22 ad hoc: + +```sh +npx -y node@22 .yarn/releases/yarn-4.14.1.cjs +``` + You then have two options to start Apollo3 for development purposes. In both cases, the instance is then accessible via diff --git a/Dockerfile b/Dockerfile index 5214d3a6e..717bd9444 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,14 @@ -# syntax=docker/dockerfile:1 - -FROM node:24 AS setup -WORKDIR /app -COPY package.json yarn.lock .yarnrc.yml ./ -COPY .yarn/ .yarn/ -COPY packages/ packages/ -RUN find packages/ -type f \! \( -name "package.json" -o -name "yarn.lock" \) -delete && \ -find . -type d -empty -delete - -FROM node:24 AS build -WORKDIR /app -COPY --from=setup /app . -RUN yarn install --immutable -COPY . . -WORKDIR /app/packages/apollo-collaboration-server -RUN yarn build - FROM node:24 LABEL org.opencontainers.image.source=https://github.com/GMOD/Apollo3 LABEL org.opencontainers.image.description="Apollo collaboration server" WORKDIR /app -COPY --from=setup /app . -COPY --from=build /app/packages/apollo-collaboration-server/dist /app/packages/apollo-collaboration-server/dist -COPY --from=build /app/packages/apollo-common/dist /app/packages/apollo-common/dist -COPY --from=build /app/packages/apollo-mst/dist /app/packages/apollo-mst/dist -COPY --from=build /app/packages/apollo-schemas/dist /app/packages/apollo-schemas/dist -COPY --from=build /app/packages/apollo-shared/dist /app/packages/apollo-shared/dist +COPY package.json yarn.lock .yarnrc.yml ./ +COPY .yarn/ .yarn/ +COPY packages/apollo-collaboration-server packages/apollo-collaboration-server +COPY packages/apollo-common packages/apollo-common +COPY packages/apollo-mst packages/apollo-mst +COPY packages/apollo-schemas packages/apollo-schemas +COPY packages/apollo-shared packages/apollo-shared RUN yarn workspaces focus --production @apollo-annotation/collaboration-server EXPOSE 3999 CMD ["yarn", "start:prod"] diff --git a/README.md b/README.md index 5a4b3294b..549efc9ba 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,32 @@ Monorepo for Apollo3 development +## Current integration status (2026-06-09) + +- Plugin authentication UX now surfaces failed local login attempts clearly + instead of appearing to pass through. +- Apollo plugin menu now includes a visible signed-in indicator + (`Signed in as: ` when available). +- Local USDA integration stack currently expects the plugin UMD bundle from + `packages/jbrowse-plugin-apollo/dist/jbrowse-plugin-apollo.umd.development.js`. + +## Local integration workflow + +For the NAL local stack (served through `jbrowse-apollo-infra`): + +1. Build shared package if needed: + - `yarn --cwd packages/apollo-shared build` +2. Build plugin bundle: + - `yarn --cwd packages/jbrowse-plugin-apollo build` +3. Start/restart infra stack from `../jbrowse-apollo-infra`: + - `./scripts/dev/start_local_stack.sh` + +## Where to look next + +- Infrastructure status and run commands: `../jbrowse-apollo-infra/README.md` +- Active handoff notes and open risks: + `../jbrowse-apollo-infra/HANDOFF_APOLLO3_JBROWSE2.md` + | Package | Description | | ---------------------------------------------------------------------- | ---------------------------------------------------------- | | [apollo-collaboration-server](./packages/apollo-collaboration-server/) | Server-side code | diff --git a/package.json b/package.json index 3c7d25a8f..08c836dd5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "packages/*" ], "scripts": { + "check:node": "node -e \"const major=Number(process.versions.node.split('.')[0]); if (major>=26){console.error('Apollo3 test tooling is not compatible with Node '+process.versions.node+'. Use Node 20 or 22.'); process.exit(1)} console.log('Node version OK for Apollo3 test tooling:', process.versions.node)\"", "lint": "cross-env NODE_OPTIONS='--max-old-space-size=4096' yarn eslint --max-warnings 0", "build:shared": "yarn workspace @apollo-annotation/shared run build", "release": "tsx scripts/makeGitTag.ts", @@ -14,7 +15,7 @@ "start:server": "yarn workspace @apollo-annotation/collaboration-server run start", "start:plugin": "yarn workspace @apollo-annotation/jbrowse-plugin-apollo run start", "start": "npm-run-all --print-label --parallel start:shared start:server start:plugin", - "test": "yarn workspaces foreach --all run test:ci", + "test": "yarn check:node && yarn workspaces foreach --all run test:ci", "postinstall": "husky" }, "devDependencies": { diff --git a/packages/apollo-cli/README.md b/packages/apollo-cli/README.md index 460cf0685..e4a87d453 100644 --- a/packages/apollo-cli/README.md +++ b/packages/apollo-cli/README.md @@ -16,7 +16,7 @@ $ npm install -g @apollo-annotation/cli $ apollo COMMAND running command... $ apollo (--version) -@apollo-annotation/cli/1.0.0 linux-x64 node-v24.15.0 +@apollo-annotation/cli/1.0.0 darwin-arm64 node-v26.0.0 $ apollo --help [COMMAND] USAGE $ apollo COMMAND @@ -62,6 +62,9 @@ USAGE - [`apollo jbrowse set-config INPUTFILE`](#apollo-jbrowse-set-config-inputfile) - [`apollo login`](#apollo-login) - [`apollo logout`](#apollo-logout) +- [`apollo permissions grant`](#apollo-permissions-grant) +- [`apollo permissions list`](#apollo-permissions-list) +- [`apollo permissions revoke`](#apollo-permissions-revoke) - [`apollo refseq add-alias INPUT-FILE`](#apollo-refseq-add-alias-input-file) - [`apollo refseq get`](#apollo-refseq-get) - [`apollo status`](#apollo-status) @@ -335,8 +338,8 @@ DESCRIPTION Address and port e.g http://localhost:3999 - accessType: - How to access Apollo. accessType is typically one of: google, microsoft, guest, root. Allowed types depend on your - Apollo setup + How to access Apollo. accessType is typically one of: google, microsoft, logingov, guest, root. Allowed types depend + on your Apollo setup - accessToken: Access token. Usually inserted by `apollo login` @@ -1019,7 +1022,7 @@ DESCRIPTION ``` _See code: -[@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.8//workspaces/Apollo3/.yarn/cache/@oclif-plugin-help-npm-6.0.8-336726b8e7-4b3be03d8a.zip/node_modules/@oclif/plugin-help/lib/commands/help.ts)_ +[@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.8/src/commands/help.ts)_ ## `apollo jbrowse desktop JBROWSEFILE` @@ -1190,6 +1193,104 @@ EXAMPLES _See code: [src/commands/logout.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/logout.ts)_ +## `apollo permissions grant` + +Grant assembly permissions to a user + +``` +USAGE + $ apollo permissions grant -u -a [--profile ] [--config-file ] [--view] [--edit] + +FLAGS + -a, --assembly=... (required) Assembly name or id + -u, --user= (required) User id, username, or email + --config-file= Use this config file (mostly for testing) + --edit Grant edit permission (implies view) + --profile= Use credentials from this profile + --view Grant view permission + +DESCRIPTION + Grant assembly permissions to a user + + Grants annotation permissions for one user across one or more assemblies. + +EXAMPLES + Grant view access to one assembly: + + $ apollo permissions grant -u user@example.org -a myAssembly --view + + Grant edit access to multiple assemblies (implies view): + + $ apollo permissions grant -u user@example.org -a asm1 asm2 --edit +``` + +_See code: +[src/commands/permissions/grant.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/grant.ts)_ + +## `apollo permissions list` + +List assembly permissions + +``` +USAGE + $ apollo permissions list [--profile ] [--config-file ] [-u ] [-a ] + +FLAGS + -a, --assembly= Filter by one assembly name or id + -u, --user= Filter by user id, username, or email + --config-file= Use this config file (mostly for testing) + --profile= Use credentials from this profile + +DESCRIPTION + List assembly permissions + + Lists assembly permission documents, optionally filtered by user and/or assembly. + +EXAMPLES + List all permissions: + + $ apollo permissions list + + List permissions for one user: + + $ apollo permissions list -u user@example.org + + List permissions for one assembly: + + $ apollo permissions list -a myAssembly +``` + +_See code: +[src/commands/permissions/list.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/list.ts)_ + +## `apollo permissions revoke` + +Revoke assembly permissions from a user + +``` +USAGE + $ apollo permissions revoke -u -a [--profile ] [--config-file ] + +FLAGS + -a, --assembly=... (required) Assembly name or id + -u, --user= (required) User id, username, or email + --config-file= Use this config file (mostly for testing) + --profile= Use credentials from this profile + +DESCRIPTION + Revoke assembly permissions from a user + + Revokes annotation permissions for one user across one or more assemblies. + +EXAMPLES + Revoke access from one assembly: + + $ apollo permissions revoke -u user@example.org -a myAssembly +``` + +_See code: +[src/commands/permissions/revoke.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/revoke.ts)_ + ## `apollo refseq add-alias INPUT-FILE` Add reference name aliases from a file diff --git a/packages/apollo-cli/package.json b/packages/apollo-cli/package.json index 2db7aca08..fa54803db 100644 --- a/packages/apollo-cli/package.json +++ b/packages/apollo-cli/package.json @@ -27,6 +27,7 @@ "prepare": "yarn build", "test": "yarn tsx src/test/test.ts", "test:ci": "yarn tsx src/test/test.ts", + "test:permissions": "yarn tsx --test-name-pattern='Permissions grant/list/revoke' src/test/test.ts", "version": "oclif readme --multi --dir ../website/docs/03-guides/04-cli/ && oclif readme && git add README.md" }, "oclif": { @@ -61,6 +62,9 @@ "jbrowse": { "description": "Commands to manage the JBrowse configuration" }, + "permissions": { + "description": "Commands to manage assembly permissions" + }, "refseq": { "description": "Commands to manage reference sequences" }, diff --git a/packages/apollo-cli/src/ApolloConf.ts b/packages/apollo-cli/src/ApolloConf.ts index af106cd85..91b66c165 100644 --- a/packages/apollo-cli/src/ApolloConf.ts +++ b/packages/apollo-cli/src/ApolloConf.ts @@ -32,7 +32,7 @@ function optionDocs(): { key: string; description: string }[] { docs.push({ key: v, description: - 'How to access Apollo. accessType is typically one of: google, microsoft, guest, root. Allowed types depend on your Apollo setup', + 'How to access Apollo. accessType is typically one of: google, microsoft, logingov, guest, root. Allowed types depend on your Apollo setup', }) break } @@ -147,7 +147,13 @@ export class ApolloConf extends Conf { const profileSchema = Joi.object({ address: Joi.string().uri({ scheme: /https?/ }), - accessType: Joi.string().valid('google', 'microsoft', 'root', 'guest'), + accessType: Joi.string().valid( + 'google', + 'microsoft', + 'logingov', + 'root', + 'guest', + ), accessToken: Joi.string(), rootPassword: Joi.string().when('accessType', { is: Joi.string().valid('root'), diff --git a/packages/apollo-cli/src/commands/permissions/README.md b/packages/apollo-cli/src/commands/permissions/README.md new file mode 100644 index 000000000..a71e59656 --- /dev/null +++ b/packages/apollo-cli/src/commands/permissions/README.md @@ -0,0 +1,25 @@ +# CLI Permissions Commands + +This folder defines assembly ACL management commands for the Apollo CLI. + +Commands: + +- `apollo permissions list` +- `apollo permissions grant` +- `apollo permissions revoke` + +Resolution behavior: + +- User input supports user id, username, or email. +- Assembly input supports assembly id or common assembly name. + +Grant semantics: + +- `--edit` implies `canViewAnnotations=true`. +- If no explicit permission flags are provided to `grant`, it defaults to + view-only. + +Implementation notes: + +- Commands call collaboration-server endpoint `assemblyPermissions`. +- `grant` and `revoke` perform `PUT` upserts per assembly. diff --git a/packages/apollo-cli/src/commands/permissions/grant.ts b/packages/apollo-cli/src/commands/permissions/grant.ts new file mode 100644 index 000000000..f59563a4a --- /dev/null +++ b/packages/apollo-cli/src/commands/permissions/grant.ts @@ -0,0 +1,97 @@ +import { Flags } from '@oclif/core' +import { type RequestInit, fetch } from 'undici' + +import { BaseCommand } from '../../baseCommand.js' +import { resolveAssemblyIds, resolveUserId } from '../../permissionsUtils.js' +import { createFetchErrorMessage, localhostToAddress } from '../../utils.js' + +export default class Grant extends BaseCommand { + static summary = 'Grant assembly permissions to a user' + static description = + 'Grants annotation permissions for one user across one or more assemblies.' + + static examples = [ + { + description: 'Grant view access to one assembly:', + command: + '<%= config.bin %> <%= command.id %> -u user@example.org -a myAssembly --view', + }, + { + description: 'Grant edit access to multiple assemblies (implies view):', + command: + '<%= config.bin %> <%= command.id %> -u user@example.org -a asm1 asm2 --edit', + }, + ] + + static flags = { + user: Flags.string({ + char: 'u', + description: 'User id, username, or email', + required: true, + }), + assembly: Flags.string({ + char: 'a', + description: 'Assembly name or id', + multiple: true, + required: true, + }), + view: Flags.boolean({ + description: 'Grant view permission', + default: false, + }), + edit: Flags.boolean({ + description: 'Grant edit permission (implies view)', + default: false, + }), + } + + public async run(): Promise { + const { flags } = await this.parse(Grant) + const access = await this.getAccess() + + const userId = await resolveUserId( + access.address, + access.accessToken, + flags.user, + ) + const assemblyIds = await resolveAssemblyIds( + access.address, + access.accessToken, + flags.assembly, + ) + + const canEditAnnotations = flags.edit + const canViewAnnotations = flags.view || canEditAnnotations || !flags.edit + + const updated = [] + for (const assemblyId of assemblyIds) { + const auth: RequestInit = { + method: 'PUT', + body: JSON.stringify({ + canViewAnnotations, + canEditAnnotations, + }), + headers: { + Authorization: `Bearer ${access.accessToken}`, + 'Content-Type': 'application/json', + }, + } + const url = new URL( + localhostToAddress( + `${access.address}/assemblyPermissions/${userId}/${assemblyId}`, + ), + ) + const response = await fetch(url, auth) + if (!response.ok) { + const errorMessage = await createFetchErrorMessage( + response, + `grant failed for assembly '${assemblyId}'`, + ) + throw new Error(errorMessage) + } + updated.push(await response.json()) + } + + this.log(JSON.stringify(updated, null, 2)) + } +} diff --git a/packages/apollo-cli/src/commands/permissions/list.ts b/packages/apollo-cli/src/commands/permissions/list.ts new file mode 100644 index 000000000..66bebc139 --- /dev/null +++ b/packages/apollo-cli/src/commands/permissions/list.ts @@ -0,0 +1,89 @@ +import { Flags } from '@oclif/core' +import type { Response } from 'undici' + +import { BaseCommand } from '../../baseCommand.js' +import { resolveAssemblyIds, resolveUserId } from '../../permissionsUtils.js' +import { queryApollo } from '../../utils.js' + +export default class List extends BaseCommand { + static summary = 'List assembly permissions' + static description = + 'Lists assembly permission documents, optionally filtered by user and/or assembly.' + + static examples = [ + { + description: 'List all permissions:', + command: '<%= config.bin %> <%= command.id %>', + }, + { + description: 'List permissions for one user:', + command: '<%= config.bin %> <%= command.id %> -u user@example.org', + }, + { + description: 'List permissions for one assembly:', + command: '<%= config.bin %> <%= command.id %> -a myAssembly', + }, + ] + + static flags = { + user: Flags.string({ + char: 'u', + description: 'Filter by user id, username, or email', + }), + assembly: Flags.string({ + char: 'a', + description: 'Filter by one assembly name or id', + }), + } + + public async run(): Promise { + const { flags } = await this.parse(List) + const access = await this.getAccess() + + let userId: string | undefined + if (flags.user) { + userId = await resolveUserId( + access.address, + access.accessToken, + flags.user, + ) + } + + let assemblyId: string | undefined + if (flags.assembly) { + const assemblyIds = await resolveAssemblyIds( + access.address, + access.accessToken, + [flags.assembly], + ) + if (assemblyIds.length > 1) { + throw new Error( + `Assembly '${flags.assembly}' resolved to multiple ids, use an id to disambiguate`, + ) + } + const [resolvedAssemblyId] = assemblyIds + assemblyId = resolvedAssemblyId + } + + const queryParams = new URLSearchParams() + if (userId) { + queryParams.set('userId', userId) + } + if (assemblyId) { + queryParams.set('assemblyId', assemblyId) + } + + const endpoint = + queryParams.size > 0 + ? `assemblyPermissions?${queryParams.toString()}` + : 'assemblyPermissions' + + const response: Response = await queryApollo( + access.address, + access.accessToken, + endpoint, + ) + const permissions = (await response.json()) as object[] + this.log(JSON.stringify(permissions, null, 2)) + } +} diff --git a/packages/apollo-cli/src/commands/permissions/revoke.ts b/packages/apollo-cli/src/commands/permissions/revoke.ts new file mode 100644 index 000000000..b56e27228 --- /dev/null +++ b/packages/apollo-cli/src/commands/permissions/revoke.ts @@ -0,0 +1,81 @@ +import { Flags } from '@oclif/core' +import { type RequestInit, fetch } from 'undici' + +import { BaseCommand } from '../../baseCommand.js' +import { resolveAssemblyIds, resolveUserId } from '../../permissionsUtils.js' +import { createFetchErrorMessage, localhostToAddress } from '../../utils.js' + +export default class Revoke extends BaseCommand { + static summary = 'Revoke assembly permissions from a user' + static description = + 'Revokes annotation permissions for one user across one or more assemblies.' + + static examples = [ + { + description: 'Revoke access from one assembly:', + command: + '<%= config.bin %> <%= command.id %> -u user@example.org -a myAssembly', + }, + ] + + static flags = { + user: Flags.string({ + char: 'u', + description: 'User id, username, or email', + required: true, + }), + assembly: Flags.string({ + char: 'a', + description: 'Assembly name or id', + multiple: true, + required: true, + }), + } + + public async run(): Promise { + const { flags } = await this.parse(Revoke) + const access = await this.getAccess() + + const userId = await resolveUserId( + access.address, + access.accessToken, + flags.user, + ) + const assemblyIds = await resolveAssemblyIds( + access.address, + access.accessToken, + flags.assembly, + ) + + const updated = [] + for (const assemblyId of assemblyIds) { + const auth: RequestInit = { + method: 'PUT', + body: JSON.stringify({ + canViewAnnotations: false, + canEditAnnotations: false, + }), + headers: { + Authorization: `Bearer ${access.accessToken}`, + 'Content-Type': 'application/json', + }, + } + const url = new URL( + localhostToAddress( + `${access.address}/assemblyPermissions/${userId}/${assemblyId}`, + ), + ) + const response = await fetch(url, auth) + if (!response.ok) { + const errorMessage = await createFetchErrorMessage( + response, + `revoke failed for assembly '${assemblyId}'`, + ) + throw new Error(errorMessage) + } + updated.push(await response.json()) + } + + this.log(JSON.stringify(updated, null, 2)) + } +} diff --git a/packages/apollo-cli/src/permissionsUtils.ts b/packages/apollo-cli/src/permissionsUtils.ts new file mode 100644 index 000000000..21d8c4e8f --- /dev/null +++ b/packages/apollo-cli/src/permissionsUtils.ts @@ -0,0 +1,52 @@ +import type { Response } from 'undici' + +import { convertAssemblyNameToId, idReader, queryApollo } from './utils.js' + +interface UserDoc { + _id: string + username: string + email: string +} + +export async function resolveUserId( + address: string, + accessToken: string, + userInput: string, +): Promise { + const usersResponse: Response = await queryApollo( + address, + accessToken, + 'users', + ) + const users = (await usersResponse.json()) as UserDoc[] + + const matches = users.filter( + (user) => + user._id === userInput || + user.username === userInput || + user.email === userInput, + ) + + if (matches.length === 0) { + throw new Error(`User '${userInput}' not found`) + } + if (matches.length > 1) { + throw new Error( + `User '${userInput}' is ambiguous. Use a unique user id instead.`, + ) + } + return matches[0]._id +} + +export async function resolveAssemblyIds( + address: string, + accessToken: string, + assemblyInputs: string[], +): Promise { + const parsedInputs = await idReader(assemblyInputs) + const ids = await convertAssemblyNameToId(address, accessToken, parsedInputs) + if (ids.length === 0) { + throw new Error('No assemblies matched the input provided') + } + return ids +} diff --git a/packages/apollo-cli/src/test/README.md b/packages/apollo-cli/src/test/README.md index 508bb942c..50bcf7048 100644 --- a/packages/apollo-cli/src/test/README.md +++ b/packages/apollo-cli/src/test/README.md @@ -35,7 +35,7 @@ cd Apollo3/packages/apollo-cli - To run all tests: ``` -* yarn tsx src/test/test.ts +yarn tsx src/test/test.ts ``` - To run only tests matching aregular expression: @@ -44,8 +44,15 @@ cd Apollo3/packages/apollo-cli yarn tsx --test-name-pattern='Print help|Feature get' src/test/test.ts ``` +- To run against a non-default Apollo server address (default is + `http://localhost:3999`): + +``` +APOLLO_TEST_ADDRESS=http://localhost:4001 yarn tsx src/test/test.ts +``` + # Run docker test ``` -yarn tsx ./src/test/test_docker.ts +APOLLO_TEST_ADDRESS=http://localhost:4001 yarn tsx ./src/test/test_docker.ts ``` diff --git a/packages/apollo-cli/src/test/test.ts b/packages/apollo-cli/src/test/test.ts index 03cba1d35..fe8db843b 100644 --- a/packages/apollo-cli/src/test/test.ts +++ b/packages/apollo-cli/src/test/test.ts @@ -32,6 +32,7 @@ import { Shell, deleteAllChecks } from './utils.js' const apollo = 'yarn dev' const P = '--profile testAdmin' +const testAddress = process.env.APOLLO_TEST_ADDRESS ?? 'http://localhost:3999' // let client = MongoClient let client: MongoClient let configFile = '' @@ -49,7 +50,7 @@ void describe('Test CLI', () => { `Backup config file ${configFileBak} already exists. If safe to do so, delete it before testing`, ) } - new Shell(`${apollo} config ${P} address http://localhost:3999`) + new Shell(`${apollo} config ${P} address ${testAddress}`) new Shell(`${apollo} config ${P} accessType root`) new Shell(`${apollo} config ${P} rootPassword pass`) new Shell(`${apollo} login ${P} -f`) @@ -98,7 +99,7 @@ void describe('Test CLI', () => { assert.strictEqual(1, p.returncode) assert.ok(p.stderr.includes('Invalid setting:')) - p = new Shell(`${apollo} config ${P} ADDRESS http://localhost:3999`, false) + p = new Shell(`${apollo} config ${P} ADDRESS ${testAddress}`, false) assert.strictEqual(1, p.returncode) assert.ok(p.stderr.includes('Invalid setting:')) @@ -912,7 +913,7 @@ EOF`, const token = p.stdout.trim() const newChildFeatureID = '69408088d502fc21aea1bb0a' new Shell( - `curl -X POST http://127.0.0.1:3999/changes -d '{"typeName":"AddFeatureChange","changedIds":["${newChildFeatureID}"],"assembly":"${assembly}","addedFeature":{"_id":"${newChildFeatureID}","refSeq":"${refSeq}","min":311,"max":315,"type":"match_part","attributes":{"gff_id":["matchPart2"]}},"parentFeatureId":"${_id}"}' -H "Content-Type: application/json" -H "Authorization: Bearer ${token}"`, + `curl -X POST ${testAddress}/changes -d '{"typeName":"AddFeatureChange","changedIds":["${newChildFeatureID}"],"assembly":"${assembly}","addedFeature":{"_id":"${newChildFeatureID}","refSeq":"${refSeq}","min":311,"max":315,"type":"match_part","attributes":{"gff_id":["matchPart2"]}},"parentFeatureId":"${_id}"}' -H "Content-Type: application/json" -H "Authorization: Bearer ${token}"`, ) p = new Shell(`${apollo} feature get-indexed-id ${P} matchPart2 -a vv1`) out = JSON.parse(p.stdout) @@ -1429,10 +1430,36 @@ EOF`, assert.strictEqual(out.length, 0) }) + void globalThis.itName('Permissions grant/list/revoke', () => { + new Shell( + `${apollo} assembly add-from-fasta ${P} test_data/tiny.fasta -a permAsm -e -f`, + ) + + let p = new Shell(`${apollo} user get ${P} -r admin -u root`) + const [adminUser] = JSON.parse(p.stdout) + const userId = adminUser._id + + new Shell(`${apollo} permissions grant ${P} -u ${userId} -a permAsm --edit`) + + p = new Shell(`${apollo} permissions list ${P} -u ${userId} -a permAsm`) + let out = JSON.parse(p.stdout) + assert.strictEqual(out.length, 1) + assert.strictEqual(out[0].canViewAnnotations, true) + assert.strictEqual(out[0].canEditAnnotations, true) + + new Shell(`${apollo} permissions revoke ${P} -u ${userId} -a permAsm`) + + p = new Shell(`${apollo} permissions list ${P} -u ${userId} -a permAsm`) + out = JSON.parse(p.stdout) + assert.strictEqual(out.length, 1) + assert.strictEqual(out[0].canViewAnnotations, false) + assert.strictEqual(out[0].canEditAnnotations, false) + }) + void globalThis.itName('Apollo profile env', () => { const p = new Shell( `export APOLLO_PROFILE=testAdmin2 - ${apollo} config address http://localhost:3999 + ${apollo} config address ${testAddress} ${apollo} config accessType root ${apollo} config rootPassword pass ${apollo} login -f @@ -1448,7 +1475,7 @@ EOF`, `\ export APOLLO_DISABLE_CONFIG_CREATE=1 rm -f tmp.yml - ${apollo} config --config-file tmp.yml address http://localhost:3999`, + ${apollo} config --config-file tmp.yml address ${testAddress}`, false, ) assert.ok(p.returncode != 0) @@ -1459,7 +1486,7 @@ EOF`, `\ export APOLLO_DISABLE_CONFIG_CREATE=0 rm -f tmp.yml - ${apollo} config --config-file tmp.yml address http://localhost:3999`, + ${apollo} config --config-file tmp.yml address ${testAddress}`, ) assert.strictEqual(0, p.returncode) assert.ok(fs.existsSync('tmp.yml')) @@ -1468,7 +1495,7 @@ EOF`, `\ unset APOLLO_DISABLE_CONFIG_CREATE rm -f tmp.yml - ${apollo} config --config-file tmp.yml address http://localhost:3999`, + ${apollo} config --config-file tmp.yml address ${testAddress}`, ) assert.strictEqual(0, p.returncode) assert.ok(fs.existsSync('tmp.yml')) diff --git a/packages/apollo-cli/src/test/test_docker.ts b/packages/apollo-cli/src/test/test_docker.ts index 01f7c374e..9dfa06c77 100644 --- a/packages/apollo-cli/src/test/test_docker.ts +++ b/packages/apollo-cli/src/test/test_docker.ts @@ -9,6 +9,7 @@ const hostTmpDir = path.resolve('tmpTestDocker') const hostDataDir = path.resolve('test_data') const apollo = `docker run --network host -v ${hostTmpDir}:/root/.config/apollo-cli -v ${hostDataDir}:/data apollo` const configFile = '/root/.config/apollo-cli/config.yml' +const testAddress = process.env.APOLLO_TEST_ADDRESS ?? 'http://localhost:3999' void describe('Test Docker', () => { before(() => { @@ -22,7 +23,7 @@ void describe('Test Docker', () => { // See apollo-collaboration-server/.development.env for credentials etc. new Shell( - `${apollo} config --config-file ${configFile} address http://localhost:3999`, + `${apollo} config --config-file ${configFile} address ${testAddress}`, ) new Shell(`${apollo} config --config-file ${configFile} accessType root`) new Shell(`${apollo} config --config-file ${configFile} rootPassword pass`) @@ -55,14 +56,14 @@ void describe('Test Docker', () => { void globalThis.itName('Missing config', () => { let p = new Shell( - `${apollo} config address --config-file {hostTmpDir}/new.yml http://localhost:3999`, + `${apollo} config address --config-file {hostTmpDir}/new.yml ${testAddress}`, false, ) assert.ok(p.returncode != 0) assert.ok(p.stderr.includes('does not exist yet')) p = new Shell( - `${apollo} config address --config-file /root/.config/apollo-cli/new.yml http://localhost:3999`, + `${apollo} config address --config-file /root/.config/apollo-cli/new.yml ${testAddress}`, false, ) assert.ok(p.returncode != 0) @@ -70,7 +71,7 @@ void describe('Test Docker', () => { fs.writeFileSync(path.join(hostTmpDir, 'new.yml'), '') p = new Shell( - `${apollo} config address --config-file /root/.config/apollo-cli/new.yml http://localhost:3999`, + `${apollo} config address --config-file /root/.config/apollo-cli/new.yml ${testAddress}`, ) assert.strictEqual(p.returncode, 0) }) diff --git a/packages/apollo-collaboration-server/README.md b/packages/apollo-collaboration-server/README.md index ca2522734..a52b0e1cc 100644 --- a/packages/apollo-collaboration-server/README.md +++ b/packages/apollo-collaboration-server/README.md @@ -1 +1,31 @@ # apollo-collaboration-server + +NestJS-based Apollo3 backend API used by the JBrowse Apollo plugin. + +## Local development + +From the Apollo3 repo root: + +```bash +yarn --cwd packages/apollo-shared build +yarn --cwd packages/apollo-collaboration-server start +``` + +Default local API port is `3999`. + +## Auth endpoints used by plugin + +- `GET /auth/types` +- `POST /auth/local` +- `POST /auth/guest` + +## Health checks + +- `GET /health` + +## Notes + +- Source of truth for stack orchestration and reverse proxy behavior is in + `../jbrowse-apollo-infra`. +- For integrated local bring-up, prefer infra scripts over ad-hoc startup: + `../jbrowse-apollo-infra/scripts/dev/start_local_stack.sh`. diff --git a/packages/apollo-collaboration-server/package.json b/packages/apollo-collaboration-server/package.json index f3ab1ca6f..a7f45559e 100644 --- a/packages/apollo-collaboration-server/package.json +++ b/packages/apollo-collaboration-server/package.json @@ -15,7 +15,7 @@ "start:no-watch": "yarn build:shared && yarn start:nest", "start:prod": "NODE_ENV=production node dist/main.js", "test": "jest", - "test:cli:start": "ALLOW_ROOT_USER=true ROOT_USER_PASSWORD=pass MONGODB_URI=\"mongodb://localhost:27017/apolloTestCliDb?directConnection=true\" LOG_LEVELS=error,warn yarn start", + "test:cli:start": "ALLOW_ROOT_USER=true ROOT_USER_PASSWORD=pass MONGODB_URI=\"mongodb://localhost:27017/apolloTestCliDb?directConnection=true\" LOG_LEVELS=error,warn PORT=${APOLLO_TEST_PORT:-3999} yarn start", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", @@ -31,11 +31,22 @@ "json", "ts" ], + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, + "extensionsToTreatAsEsm": [ + ".ts" + ], "rootDir": "src", "testEnvironment": "node", "testRegex": ".*\\.spec\\.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "useESM": true + } + ] } }, "dependencies": { @@ -78,6 +89,7 @@ "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", "passport-microsoft": "^1.0.0", + "passport-openidconnect": "^0.1.2", "prop-types": "^15.8.1", "quick-lru": "^7.3.0", "react": "^18.2.0", @@ -108,6 +120,7 @@ "@types/stream-concat": "^2.0.0", "@types/supertest": "^2.0.11", "jest": "^29.6.2", + "jest-util": "^29.7.0", "mongodb": "^4.7.0", "rimraf": "^3.0.2", "serve": "^14.0.1", diff --git a/packages/apollo-collaboration-server/src/app.module.ts b/packages/apollo-collaboration-server/src/app.module.ts index 132c05664..6d0b633f0 100644 --- a/packages/apollo-collaboration-server/src/app.module.ts +++ b/packages/apollo-collaboration-server/src/app.module.ts @@ -11,6 +11,7 @@ import Joi from 'joi' import type { Connection } from 'mongoose' import { AssembliesModule } from './assemblies/assemblies.module.js' +import { AssemblyPermissionsModule } from './assemblyPermissions/assemblyPermissions.module.js' import { AuthenticationModule } from './authentication/authentication.module.js' import { ChangesModule } from './changes/changes.module.js' import { ChecksModule } from './checks/checks.module.js' @@ -51,6 +52,15 @@ const validationSchema = Joi.object({ MICROSOFT_CLIENT_ID_FILE: Joi.string(), MICROSOFT_CLIENT_SECRET: Joi.string(), MICROSOFT_CLIENT_SECRET_FILE: Joi.string(), + LOGINGOV_CLIENT_ID: Joi.string(), + LOGINGOV_CLIENT_ID_FILE: Joi.string(), + LOGINGOV_CLIENT_SECRET: Joi.string(), + LOGINGOV_CLIENT_SECRET_FILE: Joi.string(), + LOGINGOV_ISSUER_BASE_URL: Joi.string().uri(), + LOGINGOV_AUTHORIZATION_URL: Joi.string().uri(), + LOGINGOV_TOKEN_URL: Joi.string().uri(), + LOGINGOV_USER_INFO_URL: Joi.string().uri(), + LOGINGOV_SCOPE: Joi.string(), JWT_SECRET: Joi.string(), JWT_SECRET_FILE: Joi.string(), SESSION_SECRET: Joi.string(), @@ -88,6 +98,7 @@ const validationSchema = Joi.object({ DEFAULT_NEW_USER_ROLE: Joi.string() .valid('admin', 'user', 'readOnly', 'none') .default('none'), + ALLOW_LOCAL_USER_LOGIN: Joi.boolean().default(true), BROADCAST_USER_LOCATION: Joi.boolean().default(true), ALLOW_GUEST_USER: Joi.boolean().default(false), GUEST_USER_ROLE: Joi.string() @@ -119,6 +130,8 @@ const validationSchema = Joi.object({ .oxor('GOOGLE_CLIENT_SECRET', 'GOOGLE_CLIENT_SECRET_FILE') .oxor('MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_ID_FILE') .oxor('MICROSOFT_CLIENT_SECRET', 'MICROSOFT_CLIENT_SECRET_FILE') + .oxor('LOGINGOV_CLIENT_ID', 'LOGINGOV_CLIENT_ID_FILE') + .oxor('LOGINGOV_CLIENT_SECRET', 'LOGINGOV_CLIENT_SECRET_FILE') .xor('JWT_SECRET', 'JWT_SECRET_FILE') .xor('SESSION_SECRET', 'SESSION_SECRET_FILE') .xor('PLUGIN_URLS', 'PLUGIN_URLS_FILE') @@ -146,6 +159,7 @@ async function mongoDBURIFactory( @Module({ imports: [ AssembliesModule, + AssemblyPermissionsModule, AuthenticationModule, ChangesModule, ChecksModule, diff --git a/packages/apollo-collaboration-server/src/assemblies/assemblies.controller.ts b/packages/apollo-collaboration-server/src/assemblies/assemblies.controller.ts index bc2abdd2a..ba8dac754 100644 --- a/packages/apollo-collaboration-server/src/assemblies/assemblies.controller.ts +++ b/packages/apollo-collaboration-server/src/assemblies/assemblies.controller.ts @@ -6,12 +6,14 @@ import { Logger, Param, Post, + Put, } from '@nestjs/common' import { Role } from '../utils/role/role.enum.js' import { Validations } from '../utils/validation/validatation.decorator.js' import { AssembliesService } from './assemblies.service.js' +import { UpdateAssemblyDto } from './dto/update-assembly.dto.js' interface AssemblyDocument { _id: string @@ -46,4 +48,13 @@ export class AssembliesController { findOne(@Param('id') id: string) { return this.assembliesService.findOne(id) } + + @Put(':id') + @Validations(Role.Admin) + update( + @Param('id') id: string, + @Body() updateAssemblyDto: UpdateAssemblyDto, + ) { + return this.assembliesService.update(id, updateAssemblyDto) + } } diff --git a/packages/apollo-collaboration-server/src/assemblies/assemblies.service.ts b/packages/apollo-collaboration-server/src/assemblies/assemblies.service.ts index e2a13e7f3..9e648026b 100644 --- a/packages/apollo-collaboration-server/src/assemblies/assemblies.service.ts +++ b/packages/apollo-collaboration-server/src/assemblies/assemblies.service.ts @@ -27,8 +27,20 @@ export class AssembliesService { private readonly logger = new Logger(AssembliesService.name) + private normalizeScientificName(scientificName?: string) { + const normalized = scientificName?.trim() + return normalized === '' ? undefined : normalized + } + async create(createAssemblyDto: CreateAssemblyDto) { - return this.assemblyModel.create(createAssemblyDto) + const scientificName = this.normalizeScientificName( + createAssemblyDto.scientificName, + ) + return this.assemblyModel.create( + Object.assign({}, createAssemblyDto, { + scientificName, + }), + ) } async updateChecks(_id: string, checks: string[]) { @@ -82,10 +94,25 @@ export class AssembliesService { } update(id: string, updateAssemblyDto: UpdateAssemblyDto) { + const normalizedUpdateDto = Object.assign({}, updateAssemblyDto) as Record< + string, + unknown + > + if ('scientificName' in updateAssemblyDto) { + normalizedUpdateDto.scientificName = this.normalizeScientificName( + updateAssemblyDto.scientificName, + ) + } + return this.assemblyModel - .findByIdAndUpdate({ id, status: 0 }, updateAssemblyDto, { - runValidators: true, - }) + .findOneAndUpdate( + { _id: id, status: 0 }, + { $set: normalizedUpdateDto }, + { + runValidators: true, + new: true, + }, + ) .exec() } diff --git a/packages/apollo-collaboration-server/src/assemblies/dto/create-assembly.dto.ts b/packages/apollo-collaboration-server/src/assemblies/dto/create-assembly.dto.ts index 5263d41b3..928719ddd 100644 --- a/packages/apollo-collaboration-server/src/assemblies/dto/create-assembly.dto.ts +++ b/packages/apollo-collaboration-server/src/assemblies/dto/create-assembly.dto.ts @@ -1,6 +1,7 @@ export class CreateAssemblyDto { readonly name: string readonly displayName?: string + readonly scientificName?: string readonly description?: string readonly aliases?: string[] } diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/README.md b/packages/apollo-collaboration-server/src/assemblyPermissions/README.md new file mode 100644 index 000000000..b410da696 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/README.md @@ -0,0 +1,92 @@ +# Assembly Permissions Module + +This module introduces per-assembly Apollo annotation permissions. + +## What this module owns + +- Persistence of user x assembly grants (`canViewAnnotations`, + `canEditAnnotations`) +- Admin CRUD API for grants +- Helper methods consumed by other modules (for example, write-path ACL checks + in changes) + +## Data model + +The schema is defined in: + +- `@apollo-annotation/schemas/src/assemblyPermission.schema.ts` + +Key invariants: + +- Unique grant document per `(userId, assemblyId)` +- `canEditAnnotations=true` always implies `canViewAnnotations=true` + +## Endpoints + +All endpoints are admin-only via `@Validations(Role.Admin)`. + +- `GET /assemblyPermissions?userId=...&assemblyId=...` + - list by optional filters +- `GET /assemblyPermissions/byUser/:userId` + - list all grants for a user +- `GET /assemblyPermissions/byAssembly/:assemblyId` + - list all grants for an assembly +- `PUT /assemblyPermissions/:userId/:assemblyId` + - upsert one grant + +Request body for `PUT`: + +```json +{ + "canViewAnnotations": true, + "canEditAnnotations": false +} +``` + +The controller extracts `req.user` and stores the caller as +`createdBy`/`updatedBy` metadata. + +## Slice 2 write-path enforcement + +`ChangesService.create()` now enforces assembly edit grants before applying +assembly-specific changes: + +- Admin users bypass assembly grant checks +- Non-admin users must satisfy `canEdit(user.id, change.assembly)` +- Unauthorized write attempts return `UnprocessableEntityException` + +Implementation location: + +- `changes/changes.service.ts` + +## Slice 2b read-path enforcement + +Read operations now apply assembly view permissions. + +- Features read endpoints (range, text search, count, get by id/indexed id, + list) only return data from assemblies the caller can view +- Change history queries are filtered to assemblies the caller can view +- Admin users bypass assembly ACL filtering + +Implementation locations: + +- `features/features.controller.ts` +- `features/features.service.ts` +- `changes/changes.controller.ts` +- `changes/changes.service.ts` + +## Testing + +Current tests for this module: + +- `assemblyPermissions.service.spec.ts` (service behavior and upsert rule) +- `assemblyPermissions.controller.spec.ts` (controller definition) +- `assemblyPermissions.integration.spec.ts` (HTTP route wiring with mocked + service) + +Recommended future test additions: + +- Persistence-level tests against a test MongoDB (compound index + validation + hook) +- Integration tests with auth guards enabled +- End-to-end tests validating denied/allowed write changes through `/changes` diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.spec.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.spec.ts new file mode 100644 index 000000000..557ee9d02 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.spec.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { Test, type TestingModule } from '@nestjs/testing' + +import { AssemblyPermissionsController } from './assemblyPermissions.controller.js' +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' + +describe('AssemblyPermissionsController', () => { + let controller: AssemblyPermissionsController + + const serviceMock = { + find: jest.fn(), + findByUser: jest.fn(), + findEffectiveByUser: jest.fn(), + findByAssembly: jest.fn(), + upsertPermission: jest.fn(), + findGroups: jest.fn(), + createGroup: jest.fn(), + deleteGroup: jest.fn(), + findGroupMembershipsByUser: jest.fn(), + findGroupMembershipsByGroup: jest.fn(), + setGroupMembership: jest.fn(), + findGroupPermissions: jest.fn(), + upsertGroupPermission: jest.fn(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AssemblyPermissionsController], + providers: [ + { + provide: AssemblyPermissionsService, + useValue: serviceMock, + }, + ], + }).compile() + + controller = module.get( + AssemblyPermissionsController, + ) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) +}) diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.ts new file mode 100644 index 000000000..7b0054931 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.ts @@ -0,0 +1,157 @@ +import type { DecodedJWT } from '@apollo-annotation/shared' +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + Req, +} from '@nestjs/common' +import type { Request } from 'express' + +import { Role } from '../utils/role/role.enum.js' +import { Validations } from '../utils/validation/validatation.decorator.js' + +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' +import { UpdateAssemblyPermissionDto } from './dto/update-assembly-permission.dto.js' + +interface CreateGroupDto { + name: string + description?: string +} + +interface UpdateGroupMembershipDto { + isMember: boolean +} + +@Validations(Role.Admin) +@Controller('assemblyPermissions') +export class AssemblyPermissionsController { + constructor( + private readonly assemblyPermissionsService: AssemblyPermissionsService, + ) {} + + @Get() + find( + @Query('userId') userId?: string, + @Query('assemblyId') assemblyId?: string, + ) { + // eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-method-this-argument + return this.assemblyPermissionsService.find(userId, assemblyId) + } + + @Get('byUser/:userId') + findByUser(@Param('userId') userId: string) { + return this.assemblyPermissionsService.findByUser(userId) + } + + @Validations(Role.ReadOnly) + @Get('mine') + findMine(@Req() req: Request) { + const { user } = req as unknown as { user?: DecodedJWT } + if (!user?.id) { + return [] + } + return this.assemblyPermissionsService.findEffectiveByUser(user.id) + } + + @Get('effective/byUser/:userId') + findEffectiveByUser(@Param('userId') userId: string) { + return this.assemblyPermissionsService.findEffectiveByUser(userId) + } + + @Get('byAssembly/:assemblyId') + findByAssembly(@Param('assemblyId') assemblyId: string) { + return this.assemblyPermissionsService.findByAssembly(assemblyId) + } + + @Put(':userId/:assemblyId') + upsertPermission( + @Param('userId') userId: string, + @Param('assemblyId') assemblyId: string, + @Body() permission: UpdateAssemblyPermissionDto, + @Req() req: Request, + ) { + const { user } = req as unknown as { user?: DecodedJWT } + const actor = user?.email ?? user?.username + return this.assemblyPermissionsService.upsertPermission( + userId, + assemblyId, + permission, + actor, + ) + } + + @Get('groups') + findGroups() { + return this.assemblyPermissionsService.findGroups() + } + + @Post('groups') + createGroup(@Body() body: CreateGroupDto, @Req() req: Request) { + const { user } = req as unknown as { user?: DecodedJWT } + const actor = user?.email ?? user?.username + return this.assemblyPermissionsService.createGroup( + body.name, + body.description, + actor, + ) + } + + @Delete('groups/:groupId') + deleteGroup(@Param('groupId') groupId: string) { + return this.assemblyPermissionsService.deleteGroup(groupId) + } + + @Get('groups/memberships/byUser/:userId') + findGroupMembershipsByUser(@Param('userId') userId: string) { + return this.assemblyPermissionsService.findGroupMembershipsByUser(userId) + } + + @Get('groups/memberships/byGroup/:groupId') + findGroupMembershipsByGroup(@Param('groupId') groupId: string) { + return this.assemblyPermissionsService.findGroupMembershipsByGroup(groupId) + } + + @Put('groups/memberships/:groupId/:userId') + setGroupMembership( + @Param('groupId') groupId: string, + @Param('userId') userId: string, + @Body() body: UpdateGroupMembershipDto, + @Req() req: Request, + ) { + const { user } = req as unknown as { user?: DecodedJWT } + const actor = user?.email ?? user?.username + return this.assemblyPermissionsService.setGroupMembership( + groupId, + userId, + Boolean(body.isMember), + actor, + ) + } + + @Get('groups/permissions/:groupId') + findGroupPermissions(@Param('groupId') groupId: string) { + return this.assemblyPermissionsService.findGroupPermissions(groupId) + } + + @Put('groups/permissions/:groupId/:assemblyId') + upsertGroupPermission( + @Param('groupId') groupId: string, + @Param('assemblyId') assemblyId: string, + @Body() permission: UpdateAssemblyPermissionDto, + @Req() req: Request, + ) { + const { user } = req as unknown as { user?: DecodedJWT } + const actor = user?.email ?? user?.username + return this.assemblyPermissionsService.upsertGroupPermission( + groupId, + assemblyId, + permission, + actor, + ) + } +} diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.integration.spec.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.integration.spec.ts new file mode 100644 index 000000000..a8a379e15 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.integration.spec.ts @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals' +import type { INestApplication } from '@nestjs/common' +import { Test, type TestingModule } from '@nestjs/testing' +import request from 'supertest' + +import { AssemblyPermissionsController } from './assemblyPermissions.controller.js' +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' + +describe('AssemblyPermissionsController (integration)', () => { + let app: INestApplication + + type AnyAsyncCall = (...args: unknown[]) => Promise + const asyncMock = (value: unknown) => + jest.fn().mockResolvedValue(value) + + const serviceMock = { + find: asyncMock([]), + findByUser: asyncMock([]), + findEffectiveByUser: asyncMock([]), + findByAssembly: asyncMock([]), + upsertPermission: asyncMock({ + userId: 'user1', + assemblyId: 'assembly1', + canViewAnnotations: true, + canEditAnnotations: true, + }), + findGroups: asyncMock([]), + createGroup: asyncMock({ _id: 'group1', name: 'group1' }), + deleteGroup: asyncMock({ deleted: true }), + findGroupMembershipsByUser: asyncMock([]), + findGroupMembershipsByGroup: asyncMock([]), + setGroupMembership: asyncMock({ + groupId: 'group1', + userId: 'u1', + isMember: true, + }), + findGroupPermissions: asyncMock([]), + upsertGroupPermission: asyncMock({ + groupId: 'group1', + assemblyId: 'a1', + canViewAnnotations: true, + canEditAnnotations: false, + }), + } + + beforeEach(async () => { + jest.clearAllMocks() + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [AssemblyPermissionsController], + providers: [ + { + provide: AssemblyPermissionsService, + useValue: serviceMock, + }, + ], + }).compile() + + app = moduleFixture.createNestApplication() + app.use((req, _res, next) => { + req.user = { + username: 'admin', + email: 'admin@test', + } + next() + }) + await app.init() + }) + + afterEach(async () => { + await app.close() + }) + + it('GET /assemblyPermissions forwards query filters', async () => { + await request(app.getHttpServer()) + .get('/assemblyPermissions') + .query({ userId: 'u1', assemblyId: 'a1' }) + .expect(200) + + expect(serviceMock.find).toHaveBeenCalledWith('u1', 'a1') + }) + + it('GET /assemblyPermissions/byUser/:userId forwards user id', async () => { + await request(app.getHttpServer()) + .get('/assemblyPermissions/byUser/u1') + .expect(200) + + expect(serviceMock.findByUser).toHaveBeenCalledWith('u1') + }) + + it('GET /assemblyPermissions/byAssembly/:assemblyId forwards assembly id', async () => { + await request(app.getHttpServer()) + .get('/assemblyPermissions/byAssembly/a1') + .expect(200) + + expect(serviceMock.findByAssembly).toHaveBeenCalledWith('a1') + }) + + it('PUT /assemblyPermissions/:userId/:assemblyId includes actor from req.user', async () => { + await request(app.getHttpServer()) + .put('/assemblyPermissions/u1/a1') + .send({ canViewAnnotations: false, canEditAnnotations: true }) + .expect(200) + + expect(serviceMock.upsertPermission).toHaveBeenCalledWith( + 'u1', + 'a1', + { + canViewAnnotations: false, + canEditAnnotations: true, + }, + 'admin@test', + ) + }) + + it('GET /assemblyPermissions/groups forwards to service', async () => { + await request(app.getHttpServer()) + .get('/assemblyPermissions/groups') + .expect(200) + + expect(serviceMock.findGroups).toHaveBeenCalled() + }) + + it('PUT /assemblyPermissions/groups/memberships/:groupId/:userId forwards body and actor', async () => { + await request(app.getHttpServer()) + .put('/assemblyPermissions/groups/memberships/group1/u1') + .send({ isMember: true }) + .expect(200) + + expect(serviceMock.setGroupMembership).toHaveBeenCalledWith( + 'group1', + 'u1', + true, + 'admin@test', + ) + }) +}) diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.module.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.module.ts new file mode 100644 index 000000000..0ffb93de1 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.module.ts @@ -0,0 +1,33 @@ +import { + AssemblyPermission, + AssemblyPermissionSchema, + Group, + GroupAssemblyPermission, + GroupAssemblyPermissionSchema, + GroupMembership, + GroupMembershipSchema, + GroupSchema, +} from '@apollo-annotation/schemas' +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' + +import { AssemblyPermissionsController } from './assemblyPermissions.controller.js' +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' + +@Module({ + controllers: [AssemblyPermissionsController], + providers: [AssemblyPermissionsService], + imports: [ + MongooseModule.forFeature([ + { name: AssemblyPermission.name, schema: AssemblyPermissionSchema }, + { name: Group.name, schema: GroupSchema }, + { name: GroupMembership.name, schema: GroupMembershipSchema }, + { + name: GroupAssemblyPermission.name, + schema: GroupAssemblyPermissionSchema, + }, + ]), + ], + exports: [AssemblyPermissionsService, MongooseModule], +}) +export class AssemblyPermissionsModule {} diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.spec.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.spec.ts new file mode 100644 index 000000000..0af5afe67 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.spec.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { getModelToken } from '@nestjs/mongoose' +import { Test, type TestingModule } from '@nestjs/testing' + +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' +import type { UpdateAssemblyPermissionDto } from './dto/update-assembly-permission.dto.js' + +describe('AssemblyPermissionsService', () => { + let service: AssemblyPermissionsService + + type AnyCall = (...args: unknown[]) => R + interface AsyncExec { + exec: jest.MockedFunction<() => Promise> + } + + const makeExec = (): jest.MockedFunction<() => Promise> => + jest.fn<() => Promise>() + const makeQuery = (exec: AsyncExec) => + jest.fn>>(() => exec) + + const execMock = makeExec() + const findMock = makeQuery({ exec: execMock }) + const findOneMock = makeQuery({ exec: execMock }) + const findOneAndUpdateMock = makeQuery({ exec: execMock }) + + const groupExecMock = makeExec() + const groupFindMock = makeQuery({ exec: groupExecMock }) + const groupFindOneAndUpdateMock = makeQuery({ exec: groupExecMock }) + const groupDeleteOneMock = makeQuery({ exec: groupExecMock }) + const groupDeleteManyMock = makeQuery({ exec: groupExecMock }) + + const groupFindOneMock = makeQuery({ exec: groupExecMock }) + const groupCreateMock = + jest.fn>>() + + const modelMock = { + find: findMock, + findOne: findOneMock, + findOneAndUpdate: findOneAndUpdateMock, + } + + const groupModelMock = { + find: groupFindMock, + findOne: groupFindOneMock, + create: groupCreateMock, + deleteOne: groupDeleteOneMock, + } + + const groupMembershipModelMock = { + find: groupFindMock, + findOneAndUpdate: groupFindOneAndUpdateMock, + deleteOne: groupDeleteOneMock, + deleteMany: groupDeleteManyMock, + } + + const groupAssemblyPermissionModelMock = { + find: groupFindMock, + findOneAndUpdate: groupFindOneAndUpdateMock, + deleteMany: groupDeleteManyMock, + } + + beforeEach(async () => { + jest.clearAllMocks() + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssemblyPermissionsService, + { + provide: getModelToken('AssemblyPermission'), + useValue: modelMock, + }, + { + provide: getModelToken('Group'), + useValue: groupModelMock, + }, + { + provide: getModelToken('GroupMembership'), + useValue: groupMembershipModelMock, + }, + { + provide: getModelToken('GroupAssemblyPermission'), + useValue: groupAssemblyPermissionModelMock, + }, + ], + }).compile() + + service = module.get(AssemblyPermissionsService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('find should pass query params to model', async () => { + execMock.mockResolvedValueOnce([]) + + await service.find('user123', 'assembly456') + + expect(findMock).toHaveBeenCalledWith({ + userId: 'user123', + assemblyId: 'assembly456', + }) + }) + + it('upsertPermission should force canViewAnnotations true when canEditAnnotations is true', async () => { + execMock.mockResolvedValueOnce({}) + const dto: UpdateAssemblyPermissionDto = { + canViewAnnotations: false, + canEditAnnotations: true, + } + + await service.upsertPermission('user123', 'assembly456', dto, 'admin@test') + + expect(findOneAndUpdateMock).toHaveBeenCalledWith( + { userId: 'user123', assemblyId: 'assembly456' }, + { + $set: { + canViewAnnotations: true, + canEditAnnotations: true, + updatedBy: 'admin@test', + }, + $setOnInsert: { + createdBy: 'admin@test', + }, + }, + { + new: true, + upsert: true, + runValidators: true, + }, + ) + }) + + it('canEdit should return true when matching permission has canEditAnnotations', async () => { + execMock.mockResolvedValueOnce({ canEditAnnotations: true }) + groupExecMock.mockResolvedValueOnce([]) + + const result = await service.canEdit('user123', 'assembly456') + + expect(findOneMock).toHaveBeenCalledWith({ + userId: 'user123', + assemblyId: 'assembly456', + }) + expect(result).toBe(true) + }) + + it('canEdit should return false when no matching permission exists', async () => { + execMock.mockResolvedValueOnce(null) + groupExecMock.mockResolvedValueOnce([]) + + const result = await service.canEdit('user123', 'assembly456') + + expect(result).toBe(false) + }) + + it('ensureAssemblyAccessGroup should return existing group when present', async () => { + const existingGroup = { _id: 'group1', name: 'assembly:foo' } + groupExecMock.mockResolvedValueOnce(existingGroup) + + const result = await service.ensureAssemblyAccessGroup('foo', 'admin@test') + + expect(groupModelMock.findOne).toHaveBeenCalledWith({ + name: 'assembly:foo', + }) + expect(groupModelMock.create).not.toHaveBeenCalled() + expect(result).toBe(existingGroup) + }) + + it('ensureAssemblyAccessGroup should create group when not present', async () => { + groupExecMock.mockResolvedValueOnce(null) + const createdGroup = { _id: 'group2', name: 'assembly:bar' } + groupCreateMock.mockResolvedValueOnce(createdGroup) + + const result = await service.ensureAssemblyAccessGroup('bar', 'admin@test') + + expect(groupModelMock.create).toHaveBeenCalledWith({ + name: 'assembly:bar', + description: 'Auto-created access group for assembly bar', + createdBy: 'admin@test', + updatedBy: 'admin@test', + }) + expect(result).toBe(createdGroup) + }) +}) diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.ts new file mode 100644 index 000000000..e72c7761d --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.ts @@ -0,0 +1,343 @@ +import { + AssemblyPermission, + type AssemblyPermissionDocument, + Group, + GroupAssemblyPermission, + type GroupAssemblyPermissionDocument, + type GroupDocument, + GroupMembership, + type GroupMembershipDocument, +} from '@apollo-annotation/schemas' +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { type FilterQuery, Model } from 'mongoose' + +import { UpdateAssemblyPermissionDto } from './dto/update-assembly-permission.dto.js' + +@Injectable() +export class AssemblyPermissionsService { + constructor( + @InjectModel(AssemblyPermission.name) + private readonly assemblyPermissionModel: Model, + @InjectModel(Group.name) + private readonly groupModel: Model, + @InjectModel(GroupMembership.name) + private readonly groupMembershipModel: Model, + @InjectModel(GroupAssemblyPermission.name) + private readonly groupAssemblyPermissionModel: Model, + ) {} + + find( + userId?: string, + assemblyId?: string, + ): Promise { + const filters: FilterQuery = {} + if (userId) { + filters.userId = userId + } + if (assemblyId) { + filters.assemblyId = assemblyId + } + return this.assemblyPermissionModel.find({ ...filters }).exec() + } + + findByUser(userId: string): Promise { + return this.assemblyPermissionModel.find({ userId }).exec() + } + + findByAssembly(assemblyId: string): Promise { + return this.assemblyPermissionModel.find({ assemblyId }).exec() + } + + findGroups(): Promise { + return this.groupModel.find().sort({ name: 1 }).exec() + } + + getAssemblyAccessGroupName(assemblyName: string): string { + return `assembly:${assemblyName}` + } + + async ensureAssemblyAccessGroup( + assemblyName: string, + actor?: string, + ): Promise { + const name = this.getAssemblyAccessGroupName(assemblyName) + const existing = await this.groupModel.findOne({ name }).exec() + if (existing) { + return existing + } + return this.groupModel.create({ + name, + description: `Auto-created access group for assembly ${assemblyName}`, + createdBy: actor, + updatedBy: actor, + }) + } + + createGroup( + name: string, + description?: string, + actor?: string, + ): Promise { + return this.groupModel.create({ + name: name.trim(), + description: description?.trim() ?? undefined, + createdBy: actor, + updatedBy: actor, + }) + } + + async deleteGroup(groupId: string): Promise<{ deleted: boolean }> { + await this.groupMembershipModel.deleteMany({ groupId }).exec() + await this.groupAssemblyPermissionModel.deleteMany({ groupId }).exec() + const result = await this.groupModel.deleteOne({ _id: groupId }).exec() + return { deleted: result.deletedCount > 0 } + } + + findGroupMembershipsByUser( + userId: string, + ): Promise { + return this.groupMembershipModel.find({ userId }).exec() + } + + findGroupMembershipsByGroup( + groupId: string, + ): Promise { + return this.groupMembershipModel.find({ groupId }).exec() + } + + async setGroupMembership( + groupId: string, + userId: string, + isMember: boolean, + actor?: string, + ): Promise<{ groupId: string; userId: string; isMember: boolean }> { + if (isMember) { + await this.groupMembershipModel + .findOneAndUpdate( + { groupId, userId }, + { + $set: { + updatedBy: actor, + }, + $setOnInsert: { + createdBy: actor, + }, + }, + { + upsert: true, + new: true, + runValidators: true, + }, + ) + .exec() + return { groupId, userId, isMember: true } + } + + await this.groupMembershipModel.deleteOne({ groupId, userId }).exec() + return { groupId, userId, isMember: false } + } + + findGroupPermissions( + groupId: string, + ): Promise { + return this.groupAssemblyPermissionModel.find({ groupId }).exec() + } + + upsertGroupPermission( + groupId: string, + assemblyId: string, + permission: UpdateAssemblyPermissionDto, + actor?: string, + ): Promise { + const canViewAnnotations = + permission.canViewAnnotations || permission.canEditAnnotations + + return this.groupAssemblyPermissionModel + .findOneAndUpdate( + { groupId, assemblyId }, + { + $set: { + canViewAnnotations, + canEditAnnotations: permission.canEditAnnotations, + updatedBy: actor, + }, + $setOnInsert: { + createdBy: actor, + }, + }, + { + new: true, + upsert: true, + runValidators: true, + }, + ) + .exec() + } + + private async getEffectivePermissionForAssembly( + userId: string, + assemblyId: string, + ): Promise<{ canViewAnnotations: boolean; canEditAnnotations: boolean }> { + const directPermission = await this.findOne(userId, assemblyId) + const canViewAnnotations = Boolean(directPermission?.canViewAnnotations) + const canEditAnnotations = Boolean(directPermission?.canEditAnnotations) + + const memberships = await this.findGroupMembershipsByUser(userId) + const groupIds = memberships.map((membership) => membership.groupId) + if (groupIds.length === 0) { + return { canViewAnnotations, canEditAnnotations } + } + + const groupPermissions = await this.groupAssemblyPermissionModel + .find({ groupId: { $in: groupIds }, assemblyId }) + .select('canViewAnnotations canEditAnnotations') + .exec() + + return { + canViewAnnotations: + canViewAnnotations || + groupPermissions.some((permission) => permission.canViewAnnotations), + canEditAnnotations: + canEditAnnotations || + groupPermissions.some((permission) => permission.canEditAnnotations), + } + } + + async findEffectiveByUser(userId: string): Promise< + { + _id: string + userId: string + assemblyId: string + canViewAnnotations: boolean + canEditAnnotations: boolean + source: 'direct' | 'group' | 'mixed' + }[] + > { + const directPermissions = await this.findByUser(userId) + const permissionsByAssembly = new Map< + string, + { + canViewAnnotations: boolean + canEditAnnotations: boolean + hasDirect: boolean + hasGroup: boolean + } + >() + + for (const permission of directPermissions) { + permissionsByAssembly.set(permission.assemblyId.toString(), { + canViewAnnotations: Boolean(permission.canViewAnnotations), + canEditAnnotations: Boolean(permission.canEditAnnotations), + hasDirect: true, + hasGroup: false, + }) + } + + const memberships = await this.findGroupMembershipsByUser(userId) + const groupIds = memberships.map((membership) => membership.groupId) + if (groupIds.length > 0) { + const groupPermissions = await this.groupAssemblyPermissionModel + .find({ groupId: { $in: groupIds } }) + .exec() + for (const permission of groupPermissions) { + const assemblyId = permission.assemblyId.toString() + const current = permissionsByAssembly.get(assemblyId) ?? { + canViewAnnotations: false, + canEditAnnotations: false, + hasDirect: false, + hasGroup: false, + } + permissionsByAssembly.set(assemblyId, { + canViewAnnotations: + current.canViewAnnotations || + Boolean(permission.canViewAnnotations), + canEditAnnotations: + current.canEditAnnotations || + Boolean(permission.canEditAnnotations), + hasDirect: current.hasDirect, + hasGroup: true, + }) + } + } + + return [...permissionsByAssembly.entries()].map(([assemblyId, value]) => { + let source: 'mixed' | 'group' | 'direct' = 'direct' + if (value.hasDirect && value.hasGroup) { + source = 'mixed' + } else if (value.hasGroup) { + source = 'group' + } + + return { + _id: `${userId}-${assemblyId}`, + userId, + assemblyId, + canViewAnnotations: value.canViewAnnotations, + canEditAnnotations: value.canEditAnnotations, + source, + } + }) + } + + findOne( + userId: string, + assemblyId: string, + ): Promise { + return this.assemblyPermissionModel.findOne({ userId, assemblyId }).exec() + } + + async canEdit(userId: string, assemblyId: string): Promise { + const permission = await this.getEffectivePermissionForAssembly( + userId, + assemblyId, + ) + return Boolean(permission.canEditAnnotations) + } + + async canView(userId: string, assemblyId: string): Promise { + const permission = await this.getEffectivePermissionForAssembly( + userId, + assemblyId, + ) + return Boolean(permission.canViewAnnotations) + } + + async getViewableAssemblyIds(userId: string): Promise { + const effectivePermissions = await this.findEffectiveByUser(userId) + return effectivePermissions + .filter((permission) => permission.canViewAnnotations) + .map((permission) => permission.assemblyId) + } + + upsertPermission( + userId: string, + assemblyId: string, + permission: UpdateAssemblyPermissionDto, + actor?: string, + ): Promise { + const canViewAnnotations = + permission.canViewAnnotations || permission.canEditAnnotations + + return this.assemblyPermissionModel + .findOneAndUpdate( + { userId, assemblyId }, + { + $set: { + canViewAnnotations, + canEditAnnotations: permission.canEditAnnotations, + updatedBy: actor, + }, + $setOnInsert: { + createdBy: actor, + }, + }, + { + new: true, + upsert: true, + runValidators: true, + }, + ) + .exec() + } +} diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/dto/update-assembly-permission.dto.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/dto/update-assembly-permission.dto.ts new file mode 100644 index 000000000..0dceca48b --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/dto/update-assembly-permission.dto.ts @@ -0,0 +1,4 @@ +export class UpdateAssemblyPermissionDto { + canViewAnnotations: boolean + canEditAnnotations: boolean +} diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.controller.ts b/packages/apollo-collaboration-server/src/authentication/authentication.controller.ts index a73a6e608..5b131de69 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.controller.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.controller.ts @@ -13,6 +13,7 @@ import { } from '@nestjs/common' import { GoogleAuthGuard } from '../utils/google.guard.js' +import { LoginGovAuthGuard } from '../utils/logingov.guard.js' import { MicrosoftAuthGuard } from '../utils/microsoft.guard.js' import { Role } from '../utils/role/role.enum.js' import { Validations } from '../utils/validation/validatation.decorator.js' @@ -44,7 +45,7 @@ export class AuthenticationController { if (redirect_uri) { params.set('redirect_uri', redirect_uri) } - if (['google', 'microsoft', 'guest'].includes(type)) { + if (['google', 'microsoft', 'logingov', 'guest'].includes(type)) { const url = redirect_uri ? `${type}?${new URLSearchParams({ redirect_uri }).toString()}` : type @@ -67,11 +68,25 @@ export class AuthenticationController { return this.authService.handleRedirect(req) } + @Get('logingov') + @Redirect() + @UseGuards(LoginGovAuthGuard) + async loginGovHandleRedirect(@Req() req: RequestWithUserToken) { + return this.authService.handleRedirect(req) + } + @Get('guest') guestLogin() { return this.authService.guestLogin() } + @Post('local') + localLogin( + @Body() { identifier, password }: { identifier: string; password: string }, + ) { + return this.authService.localLogin(identifier, password) + } + @Post('root') rootLogin(@Body() { password }: { password: string }) { return this.authService.rootLogin(password) diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.module.ts b/packages/apollo-collaboration-server/src/authentication/authentication.module.ts index 7fa66c985..15f658aaf 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.module.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.module.ts @@ -8,6 +8,7 @@ import { PassportModule } from '@nestjs/passport' import { UsersModule } from '../users/users.module.js' import { GoogleStrategy } from '../utils/strategies/google.strategy.js' import { JwtStrategy } from '../utils/strategies/jwt.strategy.js' +import { LoginGovStrategy } from '../utils/strategies/login-gov.strategy.js' import { MicrosoftStrategy } from '../utils/strategies/microsoft.strategy.js' import { AuthenticationController } from './authentication.controller.js' @@ -47,6 +48,7 @@ async function jwtConfigFactory( AuthenticationService, JwtStrategy, GoogleStrategy, + LoginGovStrategy, MicrosoftStrategy, ], exports: [AuthenticationService], diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts b/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts index 51023256a..6982a1194 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts @@ -1,19 +1,70 @@ -import { Test, type TestingModule } from '@nestjs/testing' +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { describe, expect, it } from '@jest/globals' + +import { Role } from '../utils/role/role.enum.js' import { AuthenticationService } from './authentication.service.js' -describe('AuthenticationService', () => { - let service: AuthenticationService +type AuthServiceCtorParams = ConstructorParameters + +function makeService(config: Record) { + const configService = { + get: (key: string) => config[key], + } as AuthServiceCtorParams[2] + + return new AuthenticationService( + {} as AuthServiceCtorParams[0], + {} as AuthServiceCtorParams[1], + configService, + ) +} - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [AuthenticationService], - }).compile() +describe('AuthenticationService', () => { + it('returns login types from direct client ID values', async () => { + const service = makeService({ + DEFAULT_NEW_USER_ROLE: Role.None, + MICROSOFT_CLIENT_ID: 'ms-direct', + GOOGLE_CLIENT_ID: 'google-direct', + LOGINGOV_CLIENT_ID: 'logingov-direct', + ALLOW_GUEST_USER: true, + }) - service = module.get(AuthenticationService) + await expect(service.getLoginTypes()).resolves.toEqual([ + 'microsoft', + 'google', + 'logingov', + 'guest', + ]) }) - it('should be defined', () => { - expect(service).toBeDefined() + it('returns login types when microsoft and google IDs are configured via files', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apollo-auth-')) + const msClientIDPath = path.join(tempDir, 'microsoft-client-id') + const googleClientIDPath = path.join(tempDir, 'google-client-id') + const loginGovClientIDPath = path.join(tempDir, 'logingov-client-id') + + await fs.writeFile(msClientIDPath, ' ms-file-id \n') + await fs.writeFile(googleClientIDPath, ' google-file-id \n') + await fs.writeFile(loginGovClientIDPath, ' logingov-file-id \n') + + const service = makeService({ + DEFAULT_NEW_USER_ROLE: Role.None, + MICROSOFT_CLIENT_ID_FILE: msClientIDPath, + GOOGLE_CLIENT_ID_FILE: googleClientIDPath, + LOGINGOV_CLIENT_ID_FILE: loginGovClientIDPath, + ALLOW_GUEST_USER: false, + }) + + await expect(service.getLoginTypes()).resolves.toEqual([ + 'microsoft', + 'google', + 'logingov', + ]) + + await fs.rm(tempDir, { recursive: true, force: true }) }) }) diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.service.ts b/packages/apollo-collaboration-server/src/authentication/authentication.service.ts index e51952e50..e87c732f9 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.service.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.service.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import fs from 'node:fs/promises' import type { JWTPayload } from '@apollo-annotation/shared' @@ -33,6 +32,9 @@ interface ConfigValues { MICROSOFT_CLIENT_ID_FILE?: string GOOGLE_CLIENT_ID?: string GOOGLE_CLIENT_ID_FILE?: string + LOGINGOV_CLIENT_ID?: string + LOGINGOV_CLIENT_ID_FILE?: string + ALLOW_LOCAL_USER_LOGIN: boolean ALLOW_GUEST_USER: boolean DEFAULT_NEW_USER_ROLE: Role ROOT_USER_PASSWORD: string @@ -40,6 +42,10 @@ interface ConfigValues { const ROOT_USER_NAME = 'root' +function hasRemoteAuthValue(value?: string): boolean { + return Boolean(value) && value !== 'disabled' +} + @Injectable() export class AuthenticationService { private readonly logger = new Logger(AuthenticationService.name) @@ -80,7 +86,7 @@ export class AuthenticationService { }) microsoftClientID = clientIDFile && (await fs.readFile(clientIDFile, 'utf8')) - microsoftClientID = clientIDFile?.trim() + microsoftClientID = microsoftClientID?.trim() } let googleClientID = this.configService.get('GOOGLE_CLIENT_ID', { infer: true, @@ -90,17 +96,44 @@ export class AuthenticationService { infer: true, }) googleClientID = clientIDFile && (await fs.readFile(clientIDFile, 'utf8')) - googleClientID = clientIDFile?.trim() + googleClientID = googleClientID?.trim() + } + let loginGovClientID = this.configService.get('LOGINGOV_CLIENT_ID', { + infer: true, + }) + if (!loginGovClientID) { + const clientIDFile = this.configService.get('LOGINGOV_CLIENT_ID_FILE', { + infer: true, + }) + if (clientIDFile) { + const loginGovClientIdFileValue = await fs.readFile( + clientIDFile, + 'utf8', + ) + loginGovClientID = loginGovClientIdFileValue.trim() + } } + const allowLocalUserLogin = this.configService.get( + 'ALLOW_LOCAL_USER_LOGIN', + { + infer: true, + }, + ) const allowGuestUser = this.configService.get('ALLOW_GUEST_USER', { infer: true, }) - if (microsoftClientID) { + if (hasRemoteAuthValue(microsoftClientID)) { loginTypes.push('microsoft') } - if (googleClientID) { + if (hasRemoteAuthValue(googleClientID)) { loginTypes.push('google') } + if (hasRemoteAuthValue(loginGovClientID)) { + loginTypes.push('logingov') + } + if (allowLocalUserLogin) { + loginTypes.push('local') + } if (allowGuestUser) { loginTypes.push('guest') } @@ -134,6 +167,21 @@ export class AuthenticationService { return this.logIn(displayName, email.value) } + async loginGovLogin(profile: { + id: string + displayName?: string + emails?: { value: string }[] + _json?: { email?: string; sub?: string } + }) { + const email = profile.emails?.[0]?.value ?? profile._json?.email + if (!email) { + throw new UnauthorizedException('No email provided') + } + const displayName = + profile.displayName ?? profile._json?.sub ?? 'login.gov user' + return this.logIn(displayName, email) + } + /** * Log in as a guest * @returns Return either token with HttpResponse status 'HttpStatus.OK' OR null with 'HttpStatus.UNAUTHORIZED' @@ -148,6 +196,31 @@ export class AuthenticationService { throw new UnauthorizedException('Guest users are not allowed') } + async localLogin(identifier: string, password: string) { + const allowLocalUserLogin = this.configService.get( + 'ALLOW_LOCAL_USER_LOGIN', + { + infer: true, + }, + ) + if (!allowLocalUserLogin) { + throw new UnauthorizedException('Local users are not allowed') + } + + const user = await this.usersService.findLocalByIdentifier(identifier) + if (!user) { + throw new UnauthorizedException('Invalid username/email or password') + } + const passwordValid = await this.usersService.verifyPassword( + password, + user.passwordHash, + ) + if (!passwordValid) { + throw new UnauthorizedException('Invalid username/email or password') + } + return this.issueToken(user) + } + async rootLogin(password: string) { if (password === this.configService.get('ROOT_USER_PASSWORD')) { return this.logIn(ROOT_USER_NAME, ROOT_USER_EMAIL) @@ -193,13 +266,22 @@ export class AuthenticationService { } this.logger.debug(`User found in Mongo: ${JSON.stringify(user)}`) + return this.issueToken(user) + } + + private issueToken(user: { + username: string + email: string + role: string + id?: string + _id?: { toString(): string } + }) { const payload: JWTPayload = { username: user.username, email: user.email, - role: user.role, - id: user.id, + role: user.role as Role, + id: user.id ?? user._id?.toString() ?? '', } - // Return token with SUCCESS status const returnToken = this.jwtService.sign(payload) this.logger.debug(`User "${user.username}" has logged in`) return { token: returnToken } diff --git a/packages/apollo-collaboration-server/src/changes/ASSEMBLY_ACL_NOTES.md b/packages/apollo-collaboration-server/src/changes/ASSEMBLY_ACL_NOTES.md new file mode 100644 index 000000000..8e25b85b0 --- /dev/null +++ b/packages/apollo-collaboration-server/src/changes/ASSEMBLY_ACL_NOTES.md @@ -0,0 +1,34 @@ +# Assembly ACL in Changes Service + +## Summary + +Write-path ACL is enforced in `ChangesService.create()` before any mutation +handlers run. + +## Rule + +For assembly-specific changes: + +- `admin` role: allowed (bypass) +- `user` / `readOnly` / `none`: requires + `AssemblyPermissionsService.canEdit(user.id, change.assembly)` + +If unauthorized, the service throws `UnprocessableEntityException`. + +## Why this is here + +This check prevents unauthorized API clients from bypassing UI restrictions by +posting changes directly. UI-level hiding/disable logic is not sufficient for +security. + +## Scope currently covered + +- Assembly-scoped write operations submitted through `/changes` +- Change history reads through `/changes` are filtered by assembly view grants + for non-admin users + +## Follow-up slices + +- Read-path assembly ACL filtering in features/search/count endpoints +- Optional stricter status codes or a dedicated authorization exception class +- Include assembly ACL decisions in audit logging diff --git a/packages/apollo-collaboration-server/src/changes/changeHandlers.service.ts b/packages/apollo-collaboration-server/src/changes/changeHandlers.service.ts index 53b05c502..bdc2ebd0d 100644 --- a/packages/apollo-collaboration-server/src/changes/changeHandlers.service.ts +++ b/packages/apollo-collaboration-server/src/changes/changeHandlers.service.ts @@ -58,6 +58,7 @@ import { InjectModel } from '@nestjs/mongoose' import { RemoteFile } from 'generic-filehandle2' import { type ClientSession, Model } from 'mongoose' +import { AssemblyPermissionsService } from '../assemblyPermissions/assemblyPermissions.service.js' import { CountersService } from '../counters/counters.service.js' import { FilesService } from '../files/files.service.js' import { MessagesGateway } from '../messages/messages.gateway.js' @@ -94,6 +95,7 @@ export class ChangeHandlersService implements ChangeHandlers { private readonly countersService: CountersService, private readonly pluginsService: PluginsService, private readonly messagesGateway: MessagesGateway, + private readonly assemblyPermissionsService: AssemblyPermissionsService, ) {} private readonly logger = new Logger(ChangeHandlersService.name) @@ -1144,6 +1146,7 @@ export class ChangeHandlersService implements ChangeHandlers { private async addAssemblyFromFileIndexed( assembly: string, assemblyName: string, + scientificName: string | undefined, fileIds: { fa: string; fai: string; gzi: string }, user: string, ): Promise { @@ -1182,9 +1185,35 @@ export class ChangeHandlersService implements ChangeHandlers { } const checkDocs = await checkModel.find({ isDefault: true }).exec() const checks = checkDocs.map((checkDoc) => checkDoc._id.toHexString()) + const normalizedScientificName = scientificName?.trim() await assemblyModel.create([ - { _id: assembly, name: assemblyName, user, status: -1, fileIds, checks }, + { + _id: assembly, + name: assemblyName, + scientificName: + normalizedScientificName === '' + ? undefined + : normalizedScientificName, + user, + status: -1, + fileIds, + checks, + }, ]) + const accessGroup = + await this.assemblyPermissionsService.ensureAssemblyAccessGroup( + assemblyName, + user, + ) + await this.assemblyPermissionsService.upsertGroupPermission( + accessGroup._id.toString(), + assembly, + { + canViewAnnotations: true, + canEditAnnotations: true, + }, + user, + ) this.logger.debug( `Added new assembly "${assemblyName}", docId "${assembly}"`, ) @@ -1208,6 +1237,7 @@ export class ChangeHandlersService implements ChangeHandlers { private async addAssemblyFromFileFasta( assembly: string, assemblyName: string, + scientificName: string | undefined, fileId: string, user: string, ): Promise { @@ -1225,16 +1255,35 @@ export class ChangeHandlersService implements ChangeHandlers { } const checkDocs = await checkModel.find({ default: true }).exec() const checks = checkDocs.map((checkDoc) => checkDoc._id.toHexString()) + const normalizedScientificName = scientificName?.trim() await assemblyModel.create([ { _id: assembly, name: assemblyName, + scientificName: + normalizedScientificName === '' + ? undefined + : normalizedScientificName, user, status: -1, fileIds: { fa: fileId }, checks, }, ]) + const accessGroup = + await this.assemblyPermissionsService.ensureAssemblyAccessGroup( + assemblyName, + user, + ) + await this.assemblyPermissionsService.upsertGroupPermission( + accessGroup._id.toString(), + assembly, + { + canViewAnnotations: true, + canEditAnnotations: true, + }, + user, + ) this.logger.debug( `Added new assembly "${assemblyName}", docId "${assembly}"`, ) @@ -1365,7 +1414,7 @@ export class ChangeHandlersService implements ChangeHandlers { const { CHUNK_SIZE } = process.env const customChunkSize = CHUNK_SIZE && Number(CHUNK_SIZE) for (const c of changes) { - const { assemblyName, externalLocation } = c + const { assemblyName, scientificName, externalLocation } = c const { fa, fai, gzi } = externalLocation const sequenceAdapter = gzi ? new BgzipIndexedFasta({ @@ -1389,16 +1438,35 @@ export class ChangeHandlersService implements ChangeHandlers { } const checkDocs = await checkModel.find({ default: true }).exec() const checks = checkDocs.map((checkDoc) => checkDoc._id.toHexString()) + const normalizedScientificName = scientificName?.trim() await assemblyModel.create([ { _id: assembly, name: assemblyName, + scientificName: + normalizedScientificName === '' + ? undefined + : normalizedScientificName, user, status: -1, externalLocation, checks, }, ]) + const accessGroup = + await this.assemblyPermissionsService.ensureAssemblyAccessGroup( + assemblyName, + user, + ) + await this.assemblyPermissionsService.upsertGroupPermission( + accessGroup._id.toString(), + assembly, + { + canViewAnnotations: true, + canEditAnnotations: true, + }, + user, + ) this.logger.debug( `Added new assembly "${assemblyName}", docId "${assembly}"`, ) @@ -1427,17 +1495,19 @@ export class ChangeHandlersService implements ChangeHandlers { const { changes } = change const { user } = context for (const c of changes) { - const { assemblyName, fileIds } = c + const { assemblyName, scientificName, fileIds } = c await ('gzi' in fileIds ? this.addAssemblyFromFileIndexed( change.assembly, assemblyName, + scientificName, fileIds, user, ) : this.addAssemblyFromFileFasta( change.assembly, assemblyName, + scientificName, fileIds.fa, user, )) @@ -1452,7 +1522,7 @@ export class ChangeHandlersService implements ChangeHandlers { const { assembly, changes } = change const { user } = context for (const c of changes) { - const { assemblyName, fileIds, parseOptions } = c + const { assemblyName, scientificName, fileIds, parseOptions } = c const fileId = fileIds.fa const { FILE_UPLOAD_FOLDER } = process.env if (!FILE_UPLOAD_FOLDER) { @@ -1471,9 +1541,35 @@ export class ChangeHandlersService implements ChangeHandlers { } const checkDocs = await checkModel.find({ isDefault: true }).exec() const checks = checkDocs.map((checkDoc) => checkDoc._id.toHexString()) + const normalizedScientificName = scientificName?.trim() await assemblyModel.create([ - { _id: assembly, name: assemblyName, user, status: -1, fileId, checks }, + { + _id: assembly, + name: assemblyName, + scientificName: + normalizedScientificName === '' + ? undefined + : normalizedScientificName, + user, + status: -1, + fileId, + checks, + }, ]) + const accessGroup = + await this.assemblyPermissionsService.ensureAssemblyAccessGroup( + assemblyName, + user, + ) + await this.assemblyPermissionsService.upsertGroupPermission( + accessGroup._id.toString(), + assembly, + { + canViewAnnotations: true, + canEditAnnotations: true, + }, + user, + ) this.logger.debug( `Added new assembly "${assemblyName}", docId "${assembly}"`, ) diff --git a/packages/apollo-collaboration-server/src/changes/changes.controller.spec.ts b/packages/apollo-collaboration-server/src/changes/changes.controller.spec.ts index 03c474760..256035641 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.controller.spec.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.controller.spec.ts @@ -1,18 +1,14 @@ -import { Test, type TestingModule } from '@nestjs/testing' +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { beforeEach, describe, expect, it } from '@jest/globals' import { ChangesController } from './changes.controller.js' -import { ChangesService } from './changes.service.js' + +type ChangesControllerCtorArgs = ConstructorParameters describe('ChangesController', () => { let controller: ChangesController - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [ChangesController], - providers: [ChangesService], - }).compile() - - controller = module.get(ChangesController) + beforeEach(() => { + controller = new ChangesController({} as ChangesControllerCtorArgs[0]) }) it('should be defined', () => { diff --git a/packages/apollo-collaboration-server/src/changes/changes.controller.ts b/packages/apollo-collaboration-server/src/changes/changes.controller.ts index 902ada19e..2f9c493bd 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.controller.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.controller.ts @@ -38,8 +38,9 @@ export class ChangesController { } @Get() - async findAll(@Query() changeFilter: FindChangeDto) { + async findAll(@Query() changeFilter: FindChangeDto, @Req() request: Request) { + const { user } = request as unknown as { user: DecodedJWT } this.logger.debug(`ChangeFilter: ${JSON.stringify(changeFilter)}`) - return this.changesService.findAll(changeFilter) + return this.changesService.findAll(changeFilter, user) } } diff --git a/packages/apollo-collaboration-server/src/changes/changes.module.ts b/packages/apollo-collaboration-server/src/changes/changes.module.ts index 79a92bedb..c4e14f883 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.module.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.module.ts @@ -5,6 +5,7 @@ import { MongooseModule, getConnectionToken } from '@nestjs/mongoose' import idValidator from 'mongoose-id-validator' import { AssembliesModule } from '../assemblies/assemblies.module.js' +import { AssemblyPermissionsModule } from '../assemblyPermissions/assemblyPermissions.module.js' import { ChecksModule } from '../checks/checks.module.js' import { CountersModule } from '../counters/counters.module.js' import { FeaturesModule } from '../features/features.module.js' @@ -34,6 +35,7 @@ import { ChangesService } from './changes.service.js' }, ]), AssembliesModule, + AssemblyPermissionsModule, RefSeqsModule, RefSeqChunksModule, FeaturesModule, diff --git a/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts b/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts index 334bea9d3..8f5c404fa 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts @@ -1,19 +1,119 @@ -import { Test, type TestingModule } from '@nestjs/testing' +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { type DecodedJWT, DeleteFeatureChange } from '@apollo-annotation/shared' +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { UnprocessableEntityException } from '@nestjs/common' import { ChangesService } from './changes.service.js' +function makeExec(value: T): { exec: () => Promise } { + return { exec: jest.fn<() => Promise>().mockResolvedValue(value) } +} + +type ChangesServiceCtorArgs = ConstructorParameters +type DeleteFeatureChangeInit = ConstructorParameters< + typeof DeleteFeatureChange +>[0] + describe('ChangesService', () => { let service: ChangesService + const assemblyPermissionsService = { + canEdit: jest.fn<(userId: string, assembly: string) => Promise>(), + getViewableAssemblyIds: jest.fn<(userId: string) => Promise>(), + } + const findExec = makeExec([]) + const findSort = { sort: jest.fn(() => findExec) } + const changeModel = { + find: jest.fn(() => findSort), + countDocuments: jest.fn(() => makeExec(0)), + } + const countersService = { + getNextSequenceValue: jest.fn<(counterName: string) => Promise>(), + } + const emptyExec = makeExec([]) + const assemblyModel = { deleteMany: jest.fn(() => emptyExec) } + const refSeqModel = { deleteMany: jest.fn(() => emptyExec) } + const refSeqChunkModel = { deleteMany: jest.fn(() => emptyExec) } + const featureModel = { + db: { + transaction: jest.fn(() => Promise.resolve()), + }, + deleteMany: jest.fn(() => emptyExec), + } - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ChangesService], - }).compile() - - service = module.get(ChangesService) + beforeEach(() => { + jest.clearAllMocks() + service = new ChangesService( + featureModel as ChangesServiceCtorArgs[0], + assemblyModel as ChangesServiceCtorArgs[1], + refSeqModel as ChangesServiceCtorArgs[2], + refSeqChunkModel as ChangesServiceCtorArgs[3], + changeModel as ChangesServiceCtorArgs[4], + // Slice 2 dependency + assemblyPermissionsService as ChangesServiceCtorArgs[5], + countersService as ChangesServiceCtorArgs[6], + {} as ChangesServiceCtorArgs[7], + {} as ChangesServiceCtorArgs[8], + ) + countersService.getNextSequenceValue.mockResolvedValue(1) }) it('should be defined', () => { expect(service).toBeDefined() }) + + it('denies non-admin assembly change when user lacks edit permission', async () => { + assemblyPermissionsService.canEdit.mockResolvedValueOnce(false) + const change = new DeleteFeatureChange({ + typeName: 'DeleteFeatureChange', + assembly: 'assembly123', + changedIds: ['feature123'], + deletedFeature: { + _id: 'feature123', + refSeq: 'refSeq1', + type: 'gene', + min: 1, + max: 10, + } as DeleteFeatureChangeInit['deletedFeature'], + }) + const user: DecodedJWT = { + id: 'user123', + username: 'user1', + email: 'user1@test', + role: 'user', + iat: 1, + exp: 2, + } + + await expect(service.create(change, user)).rejects.toThrow( + UnprocessableEntityException, + ) + expect(assemblyPermissionsService.canEdit).toHaveBeenCalledWith( + 'user123', + 'assembly123', + ) + expect(countersService.getNextSequenceValue).not.toHaveBeenCalled() + }) + + it('findAll limits non-admin users to permitted assemblies', async () => { + assemblyPermissionsService.getViewableAssemblyIds.mockResolvedValueOnce([ + 'assembly123', + ]) + + const user: DecodedJWT = { + id: 'user123', + username: 'user1', + email: 'user1@test', + role: 'user', + iat: 1, + exp: 2, + } + await service.findAll({ typeName: 'DeleteFeatureChange' }, user) + + expect(changeModel.find).toHaveBeenCalledWith( + expect.objectContaining({ + assembly: { $in: ['assembly123'] }, + typeName: 'DeleteFeatureChange', + }), + ) + }) }) diff --git a/packages/apollo-collaboration-server/src/changes/changes.service.ts b/packages/apollo-collaboration-server/src/changes/changes.service.ts index e25df4f76..73d81c298 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.service.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.service.ts @@ -33,8 +33,10 @@ import { import { InjectModel } from '@nestjs/mongoose' import { type FilterQuery, Model, Types } from 'mongoose' +import { AssemblyPermissionsService } from '../assemblyPermissions/assemblyPermissions.service.js' import { CountersService } from '../counters/counters.service.js' import { MessagesGateway } from '../messages/messages.gateway.js' +import { Role } from '../utils/role/role.enum.js' import { ChangeHandlersService } from './changeHandlers.service.js' import { FindChangeDto } from './dto/find-change.dto.js' @@ -57,6 +59,7 @@ export class ChangesService { private readonly refSeqChunkModel: Model, @InjectModel(Change.name) private readonly changeModel: Model, + private readonly assemblyPermissionsService: AssemblyPermissionsService, private readonly countersService: CountersService, private readonly messagesGateway: MessagesGateway, private readonly changeHandlersService: ChangeHandlersService, @@ -67,6 +70,20 @@ export class ChangesService { async create(change: BaseChange, user: DecodedJWT) { this.logger.debug(`Requested change: ${JSON.stringify(change)}`) + // Slice 2: write operations on assembly-scoped changes require edit grant + // unless the caller is a global admin. + if (isAssemblySpecificChange(change) && user.role !== Role.Admin) { + const canEdit = await this.assemblyPermissionsService.canEdit( + user.id, + change.assembly, + ) + if (!canEdit) { + throw new UnprocessableEntityException( + `User '${user.username}' does not have edit permission for assembly '${change.assembly}'`, + ) + } + } + const sequence = await this.countersService.getNextSequenceValue('changeCounter') const uniqUserId = `${user.email}-${sequence}` // Same user can upload data from more than one client @@ -264,7 +281,7 @@ export class ChangesService { return changeDoc } - async findAll(changeFilter: FindChangeDto) { + async findAll(changeFilter: FindChangeDto, callingUser: DecodedJWT) { const { assembly, changedIds, @@ -283,6 +300,21 @@ export class ChangesService { } = changeFilter const queryCond: FilterQuery = {} + if (callingUser.role !== Role.Admin) { + const allowedAssemblyIds = + await this.assemblyPermissionsService.getViewableAssemblyIds( + callingUser.id, + ) + if (assembly) { + if (!allowedAssemblyIds.includes(assembly)) { + return { changes: [], totalCount: 0 } + } + queryCond.assembly = assembly + } else { + queryCond.assembly = { $in: allowedAssemblyIds } + } + } + if (assembly) { queryCond.assembly = assembly } diff --git a/packages/apollo-collaboration-server/src/declare.d.ts b/packages/apollo-collaboration-server/src/declare.d.ts index 5deb37ceb..3f2e2b363 100644 --- a/packages/apollo-collaboration-server/src/declare.d.ts +++ b/packages/apollo-collaboration-server/src/declare.d.ts @@ -89,3 +89,39 @@ declare module 'connect-mongodb-session' { export default connectMongoDBSession } + +declare module 'passport-openidconnect' { + import type { Strategy as PassportStrategyBase } from 'passport' + + interface Profile { + id: string + displayName?: string + emails?: { value: string }[] + _json?: Record + } + + interface StrategyOptions { + issuer: string + authorizationURL: string + tokenURL: string + userInfoURL: string + clientID: string + clientSecret: string + callbackURL?: string + scope?: string + state?: boolean + } + + class Strategy extends PassportStrategyBase { + constructor( + options: StrategyOptions, + verify: ( + issuer: string, + profile: Profile, + done: (error: unknown, user?: unknown, info?: unknown) => void, + ) => void, + ) + } + + export { Profile, Strategy, StrategyOptions } +} diff --git a/packages/apollo-collaboration-server/src/features/features.controller.spec.ts b/packages/apollo-collaboration-server/src/features/features.controller.spec.ts index d447a8e90..fe1e3cc51 100644 --- a/packages/apollo-collaboration-server/src/features/features.controller.spec.ts +++ b/packages/apollo-collaboration-server/src/features/features.controller.spec.ts @@ -1,18 +1,17 @@ -import { Test, type TestingModule } from '@nestjs/testing' +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { beforeEach, describe, expect, it } from '@jest/globals' import { FeaturesController } from './features.controller.js' -import { FeaturesService } from './features.service.js' + +type FeaturesControllerCtorArgs = ConstructorParameters< + typeof FeaturesController +> describe('FeaturesController', () => { let controller: FeaturesController - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [FeaturesController], - providers: [FeaturesService], - }).compile() - - controller = module.get(FeaturesController) + beforeEach(() => { + controller = new FeaturesController({} as FeaturesControllerCtorArgs[0]) }) it('should be defined', () => { diff --git a/packages/apollo-collaboration-server/src/features/features.controller.ts b/packages/apollo-collaboration-server/src/features/features.controller.ts index 136153b93..ae1c19fc7 100644 --- a/packages/apollo-collaboration-server/src/features/features.controller.ts +++ b/packages/apollo-collaboration-server/src/features/features.controller.ts @@ -1,3 +1,4 @@ +import type { DecodedJWT } from '@apollo-annotation/shared' import { Body, Controller, @@ -7,7 +8,9 @@ import { ParseBoolPipe, Post, Query, + Req, } from '@nestjs/common' +import type { Request } from 'express' import type { FeatureIdsSearchDto, @@ -34,8 +37,12 @@ export class FeaturesController { * http://localhost:3999/features/searchFeatures?term=exonerate */ @Get('searchFeatures') - async searchFeatures(@Query() request: { term: string; assemblies: string }) { - return this.featuresService.searchFeatures(request) + async searchFeatures( + @Query() request: { term: string; assemblies: string }, + @Req() req: Request, + ) { + const { user } = req as unknown as { user: DecodedJWT } + return this.featuresService.searchFeatures(request, user) } /** @@ -45,36 +52,52 @@ export class FeaturesController { * or if search data was not found or in case of error throw exception */ @Get('getFeatures') - getFeaturesByRange(@Query() request: FeatureRangeSearchDto) { + getFeaturesByRange( + @Query() request: FeatureRangeSearchDto, + @Req() req: Request, + ) { this.logger.debug( `getFeatures endpoint: refSeq: ${request.refSeq}, start: ${request.start}, end: ${request.end}`, ) + const { user } = req as unknown as { user: DecodedJWT } - return this.featuresService.findByRange(request) + return this.featuresService.findByRange(request, user) } @Post('getByIds') - findByFeatureIds(@Body() request: FeatureIdsSearchDto) { + findByFeatureIds(@Body() request: FeatureIdsSearchDto, @Req() req: Request) { this.logger.debug(`: featureIds: ${JSON.stringify(request.featureIds)}`) + const { user } = req as unknown as { user: DecodedJWT } return this.featuresService.findByFeatureIds( request.featureIds, request.topLevel, + user, ) } @Get('count') - async getFeatureCount(@Query() featureCountRequest: FeatureCountRequest) { + async getFeatureCount( + @Query() featureCountRequest: FeatureCountRequest, + @Req() req: Request, + ) { this.logger.debug( `Get features count by ${JSON.stringify(featureCountRequest)}`, ) - const count = - await this.featuresService.getFeatureCount(featureCountRequest) + const { user } = req as unknown as { user: DecodedJWT } + const count = await this.featuresService.getFeatureCount( + featureCountRequest, + user, + ) return { count } } @Get('getByIndexedId') - async getById(@Query() getByIndexedIdRequest: GetByIndexedIdRequest) { - return this.featuresService.getByIndexedId(getByIndexedIdRequest) + async getById( + @Query() getByIndexedIdRequest: GetByIndexedIdRequest, + @Req() req: Request, + ) { + const { user } = req as unknown as { user: DecodedJWT } + return this.featuresService.getByIndexedId(getByIndexedIdRequest, user) } /** @@ -88,9 +111,11 @@ export class FeaturesController { @Param('featureid') featureid: string, @Query('topLevel', new ParseBoolPipe({ optional: true })) topLevel: boolean | undefined, + @Req() req: Request, ) { this.logger.debug(`Get feature by featureId: ${featureid}`) - return this.featuresService.findById(featureid, topLevel) + const { user } = req as unknown as { user: DecodedJWT } + return this.featuresService.findById(featureid, topLevel, user) } @Get('check/:featureid') @@ -104,8 +129,9 @@ export class FeaturesController { * or if search data was not found or in case of error throw exception */ @Get() - getAll() { + getAll(@Req() req: Request) { this.logger.debug('Get all features') - return this.featuresService.findAll() + const { user } = req as unknown as { user: DecodedJWT } + return this.featuresService.findAll(user) } } diff --git a/packages/apollo-collaboration-server/src/features/features.module.ts b/packages/apollo-collaboration-server/src/features/features.module.ts index e9b5505e6..c2bf8db06 100644 --- a/packages/apollo-collaboration-server/src/features/features.module.ts +++ b/packages/apollo-collaboration-server/src/features/features.module.ts @@ -8,6 +8,7 @@ import { MongooseModule, getConnectionToken } from '@nestjs/mongoose' import type { Connection } from 'mongoose' import idValidator from 'mongoose-id-validator' +import { AssemblyPermissionsModule } from '../assemblyPermissions/assemblyPermissions.module.js' import { ChecksModule } from '../checks/checks.module.js' import { ChecksService } from '../checks/checks.service.js' import { RefSeqsModule } from '../refSeqs/refSeqs.module.js' @@ -21,6 +22,7 @@ import { FeaturesService } from './features.service.js' providers: [FeaturesService], imports: [ ChecksModule, + AssemblyPermissionsModule, RefSeqsModule, SequenceModule, MongooseModule.forFeatureAsync([ diff --git a/packages/apollo-collaboration-server/src/features/features.service.spec.ts b/packages/apollo-collaboration-server/src/features/features.service.spec.ts index 4349c3747..9c9468be7 100644 --- a/packages/apollo-collaboration-server/src/features/features.service.spec.ts +++ b/packages/apollo-collaboration-server/src/features/features.service.spec.ts @@ -1,16 +1,20 @@ -import { Test, type TestingModule } from '@nestjs/testing' +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { beforeEach, describe, expect, it } from '@jest/globals' import { FeaturesService } from './features.service.js' +type FeaturesServiceCtorArgs = ConstructorParameters + describe('FeaturesService', () => { let service: FeaturesService - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [FeaturesService], - }).compile() - - service = module.get(FeaturesService) + beforeEach(() => { + service = new FeaturesService( + {} as FeaturesServiceCtorArgs[0], + {} as FeaturesServiceCtorArgs[1], + {} as FeaturesServiceCtorArgs[2], + {} as FeaturesServiceCtorArgs[3], + ) }) it('should be defined', () => { diff --git a/packages/apollo-collaboration-server/src/features/features.service.ts b/packages/apollo-collaboration-server/src/features/features.service.ts index 0661abe44..e6bd245b6 100644 --- a/packages/apollo-collaboration-server/src/features/features.service.ts +++ b/packages/apollo-collaboration-server/src/features/features.service.ts @@ -5,12 +5,20 @@ import { RefSeq, type RefSeqDocument, } from '@apollo-annotation/schemas' -import { Injectable, Logger, NotFoundException } from '@nestjs/common' +import type { DecodedJWT } from '@apollo-annotation/shared' +import { + Injectable, + Logger, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' -import { Model } from 'mongoose' +import { Model, Types } from 'mongoose' +import { AssemblyPermissionsService } from '../assemblyPermissions/assemblyPermissions.service.js' import { ChecksService } from '../checks/checks.service.js' import type { FeatureRangeSearchDto } from '../entity/gff3Object.dto.js' +import { Role } from '../utils/role/role.enum.js' import type { FeatureCountRequest, @@ -20,6 +28,7 @@ import type { @Injectable() export class FeaturesService { constructor( + private readonly assemblyPermissionsService: AssemblyPermissionsService, private readonly checksService: ChecksService, @InjectModel(Feature.name) private readonly featureModel: Model, @@ -29,11 +38,69 @@ export class FeaturesService { private readonly logger = new Logger(FeaturesService.name) - findAll() { - return this.featureModel.find().exec() + private isAdmin(user: DecodedJWT) { + return user.role === Role.Admin + } + + private toAssemblyId(assembly: RefSeqDocument['assembly']) { + if (typeof assembly === 'string') { + return assembly + } + if (assembly instanceof Types.ObjectId) { + return assembly.toHexString() + } + throw new UnprocessableEntityException('Invalid refSeq assembly id type') + } + + private async ensureCanViewAssembly(user: DecodedJWT, assemblyId: string) { + if (this.isAdmin(user)) { + return + } + const canView = await this.assemblyPermissionsService.canView( + user.id, + assemblyId, + ) + if (!canView) { + throw new UnprocessableEntityException( + `User '${user.username}' does not have view permission for assembly '${assemblyId}'`, + ) + } + } + + private async getAllowedAssemblyIds( + user: DecodedJWT, + requestedAssemblyIds?: string[], + ): Promise { + if (this.isAdmin(user)) { + return requestedAssemblyIds ?? [] + } + const allowed = + await this.assemblyPermissionsService.getViewableAssemblyIds(user.id) + if (!requestedAssemblyIds) { + return allowed + } + return requestedAssemblyIds.filter((assemblyId) => + allowed.includes(assemblyId), + ) } - async getFeatureCount(featureCountRequest: FeatureCountRequest) { + async findAll(user: DecodedJWT) { + const allowedAssemblyIds = await this.getAllowedAssemblyIds(user) + if (!this.isAdmin(user) && allowedAssemblyIds.length === 0) { + return [] + } + const refSeqs = await this.refSeqModel + .find(this.isAdmin(user) ? {} : { assembly: { $in: allowedAssemblyIds } }) + .select('_id') + .exec() + const refSeqIds = refSeqs.map((refSeq) => refSeq._id) + return this.featureModel.find({ refSeq: { $in: refSeqIds } }).exec() + } + + async getFeatureCount( + featureCountRequest: FeatureCountRequest, + user: DecodedJWT, + ) { let count = 0 const { assemblyId, end, refSeqId, start } = featureCountRequest const filter: Record< @@ -49,9 +116,18 @@ export class FeaturesService { } if (refSeqId) { + const refSeqDoc = await this.refSeqModel.findById(refSeqId).exec() + if (!refSeqDoc) { + throw new NotFoundException(`RefSeq with id '${refSeqId}' not found`) + } + await this.ensureCanViewAssembly( + user, + this.toAssemblyId(refSeqDoc.assembly), + ) filter.refSeq = refSeqId count = await this.featureModel.countDocuments(filter) } else if (assemblyId) { + await this.ensureCanViewAssembly(user, assemblyId) const refSeqs: RefSeqDocument[] = await this.refSeqModel .find({ assembly: assemblyId }) .exec() @@ -61,26 +137,53 @@ export class FeaturesService { count += await this.featureModel.countDocuments(filter) } } else { - // returns count of all documents or in the range (start, end) - count = await this.featureModel.countDocuments(filter) + if (this.isAdmin(user)) { + count = await this.featureModel.countDocuments(filter) + } else { + const allowedAssemblyIds = await this.getAllowedAssemblyIds(user) + if (allowedAssemblyIds.length === 0) { + return 0 + } + const refSeqs: RefSeqDocument[] = await this.refSeqModel + .find({ assembly: { $in: allowedAssemblyIds } }) + .exec() + for (const refSeq of refSeqs) { + filter.refSeq = refSeq._id.toString() + count += await this.featureModel.countDocuments(filter) + } + } } this.logger.debug(`Number of features is ${count}`) return count } - async getByIndexedId(getByIndexedIdRequest: GetByIndexedIdRequest) { + async getByIndexedId( + getByIndexedIdRequest: GetByIndexedIdRequest, + user: DecodedJWT, + ) { const { assemblies, id, topLevel } = getByIndexedIdRequest - const refSeqsQuery: { refSeq?: RefSeqDocument[] } = {} - if (assemblies) { - const assemblyIds = assemblies.split(',') - const refSeqs = await this.refSeqModel - .find({ assembly: assemblyIds }) - .exec() - refSeqsQuery.refSeq = refSeqs + const requestedAssemblyIds = assemblies ? assemblies.split(',') : undefined + const allowedAssemblyIds = await this.getAllowedAssemblyIds( + user, + requestedAssemblyIds, + ) + if (!this.isAdmin(user) && allowedAssemblyIds.length === 0) { + return [] } + + const refSeqs = await this.refSeqModel + .find( + requestedAssemblyIds || !this.isAdmin(user) + ? { assembly: { $in: allowedAssemblyIds } } + : {}, + ) + .select('_id') + .exec() + const refSeqIds = refSeqs.map((refSeq) => refSeq._id) + const topLevelFeatures = await this.featureModel - .find({ indexedIds: id, ...refSeqsQuery }) + .find({ indexedIds: id, refSeq: { $in: refSeqIds } }) .exec() if (topLevelFeatures.length === 0) { return [] @@ -116,7 +219,11 @@ export class FeaturesService { return } - async findByFeatureIds(featureIds: string[], topLevel?: boolean) { + async findByFeatureIds( + featureIds: string[], + topLevel?: boolean, + user?: DecodedJWT, + ) { const foundFeatures: Feature[] = [] // all featureIds that have already been fetched const fetchedFeatureIds = new Set() @@ -128,7 +235,7 @@ export class FeaturesService { } try { - const feature = await this.findById(featureId, topLevel) + const feature = await this.findById(featureId, topLevel, user) foundFeatures.push(feature) for (const id of feature.allIds) { fetchedFeatureIds.add(id) @@ -149,7 +256,7 @@ export class FeaturesService { * @param topLevel - If true, return the top level feature and its children. If false, return the requested feature and its children. * @returns Return the feature(s) if search was successful. Otherwise throw exception */ - async findById(featureId: string, topLevel?: boolean) { + async findById(featureId: string, topLevel?: boolean, user?: DecodedJWT) { // Search correct feature const topLevelFeature = await this.featureModel .findOne({ allIds: featureId }) @@ -161,6 +268,21 @@ export class FeaturesService { throw new NotFoundException(errMsg) } + if (user) { + const refSeqDoc = await this.refSeqModel + .findById(topLevelFeature.refSeq) + .exec() + if (!refSeqDoc) { + throw new NotFoundException( + `RefSeq for feature '${featureId}' was not found`, + ) + } + await this.ensureCanViewAssembly( + user, + this.toAssemblyId(refSeqDoc.assembly), + ) + } + // Now we need to find correct top level feature or sub-feature inside the feature const foundFeature = this.getFeatureFromId( topLevelFeature, @@ -221,7 +343,20 @@ export class FeaturesService { return null } - async findByRange(searchDto: FeatureRangeSearchDto) { + async findByRange(searchDto: FeatureRangeSearchDto, user?: DecodedJWT) { + const refSeqDoc = await this.refSeqModel.findById(searchDto.refSeq).exec() + if (!refSeqDoc) { + throw new NotFoundException( + `RefSeq with id '${searchDto.refSeq}' not found`, + ) + } + if (user) { + await this.ensureCanViewAssembly( + user, + this.toAssemblyId(refSeqDoc.assembly), + ) + } + const featureDocs = await this.featureModel .find({ refSeq: searchDto.refSeq, @@ -245,14 +380,26 @@ export class FeaturesService { return this.checksService.checkFeature(topLevelFeature, checkTimestamps) } - async searchFeatures(searchDto: { term: string; assemblies: string }) { + async searchFeatures( + searchDto: { term: string; assemblies: string }, + user: DecodedJWT, + ) { const { assemblies, term } = searchDto - const assemblyIds = assemblies.split(',') + const requestedAssemblyIds = assemblies.split(',') + const assemblyIds = await this.getAllowedAssemblyIds( + user, + requestedAssemblyIds, + ) + if (!this.isAdmin(user) && assemblyIds.length === 0) { + return [] + } const refSeqs = await this.refSeqModel .find({ assembly: assemblyIds }) + .select('_id') .exec() + const refSeqIds = refSeqs.map((refSeq) => refSeq._id) return this.featureModel - .find({ $text: { $search: `"${term}"` }, refSeq: refSeqs }) + .find({ $text: { $search: `"${term}"` }, refSeq: { $in: refSeqIds } }) .exec() } } diff --git a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.spec.ts b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.spec.ts index 71eed0411..780940f9b 100644 --- a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.spec.ts +++ b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.spec.ts @@ -1,19 +1,70 @@ -import { Test, type TestingModule } from '@nestjs/testing' +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { describe, expect, it, jest } from '@jest/globals' + +import { Role } from '../utils/role/role.enum.js' import { JBrowseService } from './jbrowse.service.js' +type JBrowseServiceCtorArgs = ConstructorParameters +type AssembliesResult = Awaited> +type TracksResult = Awaited> + +function makeService() { + return new JBrowseService( + {} as JBrowseServiceCtorArgs[0], + {} as JBrowseServiceCtorArgs[1], + {} as JBrowseServiceCtorArgs[2], + {} as JBrowseServiceCtorArgs[3], + ) +} + describe('JBrowseService', () => { - let service: JBrowseService + it('should be defined', () => { + const service = makeService() + expect(service).toBeDefined() + }) - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [JBrowseService], - }).compile() + it('returns a minimal config for unauthenticated requests', async () => { + const service = makeService() + const configuration = { theme: {} } + const plugins = [{ name: 'Apollo', url: 'apollo.js' }] + const internetAccounts = [{ type: 'ApolloInternetAccount' }] - service = module.get(JBrowseService) + jest.spyOn(service, 'getConfiguration').mockReturnValue(configuration) + jest.spyOn(service, 'getPlugins').mockReturnValue(plugins) + jest.spyOn(service, 'getInternetAccounts').mockReturnValue(internetAccounts) + + await expect(service.getConfig()).resolves.toEqual({ + configuration, + plugins, + internetAccounts, + }) }) - it('should be defined', () => { - expect(service).toBeDefined() + it('includes assemblies and tracks for guest-authenticated requests', async () => { + const service = makeService() + const configuration = { theme: {}, ApolloPlugin: { hasRole: true } } + const assemblies = [{ name: 'assembly-1' }] as AssembliesResult + const tracks = [{ trackId: 'track-1' }] as TracksResult + const plugins = [{ name: 'Apollo', url: 'apollo.js' }] + const internetAccounts = [{ type: 'ApolloInternetAccount' }] + const defaultSession = { name: 'Apollo', views: [] } + + jest.spyOn(service, 'getConfiguration').mockReturnValue(configuration) + jest.spyOn(service, 'getAssemblies').mockResolvedValue(assemblies) + jest.spyOn(service, 'getTracks').mockResolvedValue(tracks) + jest.spyOn(service, 'getPlugins').mockReturnValue(plugins) + jest.spyOn(service, 'getInternetAccounts').mockReturnValue(internetAccounts) + jest.spyOn(service, 'getDefaultSession').mockReturnValue(defaultSession) + jest.spyOn(service, 'getJBrowseConfig').mockResolvedValue() + + await expect(service.getConfig(Role.None)).resolves.toEqual({ + configuration, + assemblies, + tracks, + plugins, + internetAccounts, + defaultSession, + }) }) }) diff --git a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts index 0c079de3e..9dd1ebe04 100644 --- a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts +++ b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts @@ -231,7 +231,7 @@ export class JBrowseService { } async getConfig(role?: Role) { - if (!role || role === Role.None) { + if (!role) { return { configuration: this.getConfiguration(role), plugins: this.getPlugins(), diff --git a/packages/apollo-collaboration-server/src/users/dto/create-user.dto.ts b/packages/apollo-collaboration-server/src/users/dto/create-user.dto.ts index 771e4d8f5..7aa5b3940 100644 --- a/packages/apollo-collaboration-server/src/users/dto/create-user.dto.ts +++ b/packages/apollo-collaboration-server/src/users/dto/create-user.dto.ts @@ -6,6 +6,10 @@ export class CreateUserDto { role?: Role } +export class CreateLocalUserDto extends CreateUserDto { + readonly password: string +} + export class UserLocationDto { readonly assemblyId: string readonly refSeq: string diff --git a/packages/apollo-collaboration-server/src/users/users.controller.ts b/packages/apollo-collaboration-server/src/users/users.controller.ts index 08d1d3701..ddc9cab0c 100644 --- a/packages/apollo-collaboration-server/src/users/users.controller.ts +++ b/packages/apollo-collaboration-server/src/users/users.controller.ts @@ -7,7 +7,7 @@ import type { Request } from 'express' import { Role } from '../utils/role/role.enum.js' import { Validations } from '../utils/validation/validatation.decorator.js' -import { UserLocationDto } from './dto/create-user.dto.js' +import { CreateLocalUserDto, UserLocationDto } from './dto/create-user.dto.js' import { UsersService } from './users.service.js' @Controller('users') @@ -52,6 +52,12 @@ export class UsersController { return this.usersService.findById(id) } + @Validations(Role.Admin) + @Post('local') + createLocalUser(@Body() createLocalUserDto: CreateLocalUserDto) { + return this.usersService.createLocalUser(createLocalUserDto) + } + // NOTE: It's important that all GET endpoints are before POST endpoint, otherwise GET endpoint that is after POST may not be called properly!! /** diff --git a/packages/apollo-collaboration-server/src/users/users.service.ts b/packages/apollo-collaboration-server/src/users/users.service.ts index afe364b11..93542ca4e 100644 --- a/packages/apollo-collaboration-server/src/users/users.service.ts +++ b/packages/apollo-collaboration-server/src/users/users.service.ts @@ -1,3 +1,10 @@ +import { + randomBytes, + scrypt as scryptCallback, + timingSafeEqual, +} from 'node:crypto' +import { promisify } from 'node:util' + import { User as UserSchema, type UserDocument, @@ -8,7 +15,7 @@ import { type UserLocationMessage, makeUserSessionId, } from '@apollo-annotation/shared' -import { Injectable, Logger } from '@nestjs/common' +import { ConflictException, Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' @@ -17,7 +24,13 @@ import { MessagesGateway } from '../messages/messages.gateway.js' import { GUEST_USER_EMAIL, GUEST_USER_NAME } from '../utils/constants.js' import { Role } from '../utils/role/role.enum.js' -import { CreateUserDto, UserLocationDto } from './dto/create-user.dto.js' +import { + CreateLocalUserDto, + CreateUserDto, + UserLocationDto, +} from './dto/create-user.dto.js' + +const scrypt = promisify(scryptCallback) export interface User { email: string @@ -57,6 +70,15 @@ export class UsersService { return this.userModel.findOne({ email }).exec() } + async findLocalByIdentifier(identifier: string) { + return this.userModel + .findOne({ + $or: [{ email: identifier }, { username: identifier }], + }) + .select('+passwordHash') + .exec() + } + async findByRole(role: Role) { return this.userModel.findOne({ role }).sort('createdAt').exec() } @@ -73,6 +95,58 @@ export class UsersService { return this.userModel.create(user) } + async createLocalUser(user: CreateLocalUserDto) { + const existingEmail = await this.findByEmail(user.email) + if (existingEmail) { + throw new ConflictException( + `User with email '${user.email}' already exists`, + ) + } + + const existingUsername = await this.findByUsername(user.username) + if (existingUsername) { + throw new ConflictException( + `User with username '${user.username}' already exists`, + ) + } + + const passwordHash = await this.hashPassword(user.password) + const createdUser = await this.userModel.create({ + email: user.email, + username: user.username, + role: user.role, + passwordHash, + }) + return { + _id: createdUser._id, + email: createdUser.email, + username: createdUser.username, + role: createdUser.role, + } + } + + async hashPassword(password: string) { + const salt = randomBytes(16) + const derivedKey = (await scrypt(password, salt, 64)) as Buffer + return `scrypt:${salt.toString('hex')}:${derivedKey.toString('hex')}` + } + + async verifyPassword(password: string, passwordHash?: string) { + if (!passwordHash) { + return false + } + const [algorithm, saltHex, hashHex] = passwordHash.split(':') + if (algorithm !== 'scrypt' || !saltHex || !hashHex) { + return false + } + const derivedKey = (await scrypt( + password, + Buffer.from(saltHex, 'hex'), + 64, + )) as Buffer + return timingSafeEqual(derivedKey, Buffer.from(hashHex, 'hex')) + } + async getCount() { return this.userModel.count().exec() } diff --git a/packages/apollo-collaboration-server/src/utils/logingov.guard.ts b/packages/apollo-collaboration-server/src/utils/logingov.guard.ts new file mode 100644 index 000000000..4dba38a90 --- /dev/null +++ b/packages/apollo-collaboration-server/src/utils/logingov.guard.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { type ExecutionContext, Injectable } from '@nestjs/common' +import { AuthGuard, type IAuthModuleOptions } from '@nestjs/passport' + +@Injectable() +export class LoginGovAuthGuard extends AuthGuard('logingov') { + getAuthenticateOptions( + context: ExecutionContext, + ): IAuthModuleOptions | undefined { + const [req] = context.getArgs() + const { query } = req + const redirectUri = query.redirect_uri + if (redirectUri) { + return { state: { redirect_uri: redirectUri } } + } + return + } +} diff --git a/packages/apollo-collaboration-server/src/utils/strategies/google.strategy.ts b/packages/apollo-collaboration-server/src/utils/strategies/google.strategy.ts index ffe41286b..a7f2c8a01 100644 --- a/packages/apollo-collaboration-server/src/utils/strategies/google.strategy.ts +++ b/packages/apollo-collaboration-server/src/utils/strategies/google.strategy.ts @@ -8,6 +8,10 @@ import { type Profile, Strategy } from 'passport-google-oauth20' import { AuthenticationService } from '../../authentication/authentication.service.js' +function isRemoteAuthEnabled(value?: string): boolean { + return Boolean(value) && value !== 'disabled' +} + interface ConfigValues { GOOGLE_CLIENT_ID?: string GOOGLE_CLIENT_ID_FILE?: string @@ -33,7 +37,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy) { }) clientID = clientIDFile && fs.readFileSync(clientIDFile, 'utf8').trim() } - const configured = Boolean(clientID) + const configured = isRemoteAuthEnabled(clientID) if (!configured) { clientID = 'none' } diff --git a/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts b/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts new file mode 100644 index 000000000..0036cad3a --- /dev/null +++ b/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts @@ -0,0 +1,130 @@ +import fs from 'node:fs' +import type { Agent as HttpsAgent } from 'node:https' + +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { PassportStrategy } from '@nestjs/passport' +import { HttpsProxyAgent } from 'https-proxy-agent' +import { Strategy } from 'passport-openidconnect' + +import { AuthenticationService } from '../../authentication/authentication.service.js' + +function isRemoteAuthEnabled(value?: string): boolean { + return Boolean(value) && value !== 'disabled' +} + +interface ConfigValues { + LOGINGOV_CLIENT_ID?: string + LOGINGOV_CLIENT_ID_FILE?: string + LOGINGOV_CLIENT_SECRET?: string + LOGINGOV_CLIENT_SECRET_FILE?: string + LOGINGOV_ISSUER_BASE_URL?: string + LOGINGOV_AUTHORIZATION_URL?: string + LOGINGOV_TOKEN_URL?: string + LOGINGOV_USER_INFO_URL?: string + LOGINGOV_SCOPE?: string + URL: string + OAUTH_HTTP_PROXY?: string +} + +interface LoginGovProfile { + id: string + displayName?: string + emails?: { value: string }[] + _json?: { email?: string; sub?: string } +} + +interface OAuth2ClientWithAgent { + setAgent(agent: HttpsAgent): void +} + +@Injectable() +export class LoginGovStrategy extends PassportStrategy(Strategy, 'logingov') { + private readonly logger = new Logger(LoginGovStrategy.name) + + constructor( + private readonly authService: AuthenticationService, + configService: ConfigService, + ) { + let clientID = configService.get('LOGINGOV_CLIENT_ID', { infer: true }) + if (!clientID) { + const clientIDFile = configService.get('LOGINGOV_CLIENT_ID_FILE', { + infer: true, + }) + clientID = clientIDFile && fs.readFileSync(clientIDFile, 'utf8').trim() + } + const configured = isRemoteAuthEnabled(clientID) + if (!configured) { + clientID = 'none' + } + + let clientSecret = 'none' + let callbackURL: string | undefined + const issuerBaseURL = + configService.get('LOGINGOV_ISSUER_BASE_URL', { infer: true }) ?? + 'https://secure.login.gov' + const authorizationURL = + configService.get('LOGINGOV_AUTHORIZATION_URL', { infer: true }) ?? + `${issuerBaseURL}/openid_connect/authorize` + const tokenURL = + configService.get('LOGINGOV_TOKEN_URL', { infer: true }) ?? + `${issuerBaseURL}/api/openid_connect/token` + const userInfoURL = + configService.get('LOGINGOV_USER_INFO_URL', { infer: true }) ?? + `${issuerBaseURL}/api/openid_connect/userinfo` + const scope = + configService.get('LOGINGOV_SCOPE', { infer: true }) ?? + 'openid email profile' + + if (configured) { + clientSecret = configService.get('LOGINGOV_CLIENT_SECRET', { + infer: true, + }) + if (!clientSecret) { + const clientSecretFile = configService.get( + 'LOGINGOV_CLIENT_SECRET_FILE', + { infer: true }, + ) + if (!clientSecretFile) { + throw new Error( + 'LOGINGOV_CLIENT_SECRET or LOGINGOV_CLIENT_SECRET_FILE must be configured when login.gov auth is enabled', + ) + } + clientSecret = fs.readFileSync(clientSecretFile, 'utf8').trim() + } + const urlString = configService.get('URL', { infer: true }) + const callbackURI = new URL(urlString) + callbackURI.pathname = `${callbackURI.pathname}${ + callbackURI.pathname.endsWith('/') ? '' : '/' + }auth/logingov` + callbackURL = callbackURI.href + } + + super({ + issuer: issuerBaseURL, + authorizationURL, + tokenURL, + userInfoURL, + clientID, + clientSecret, + callbackURL, + scope, + state: true, + }) + + const proxy = configService.get('OAUTH_HTTP_PROXY', { infer: true }) + if (proxy) { + const agent = new HttpsProxyAgent(proxy) + const oauth2 = (this as unknown as { _oauth2?: OAuth2ClientWithAgent }) + ._oauth2 + if (oauth2) { + oauth2.setAgent(agent) + this.logger.debug(`LoginGovStrategy configured to use proxy: ${proxy}`) + } + } + } + + async validate(_issuer: string, profile: LoginGovProfile) { + return this.authService.loginGovLogin(profile) + } +} diff --git a/packages/apollo-collaboration-server/src/utils/strategies/microsoft.strategy.ts b/packages/apollo-collaboration-server/src/utils/strategies/microsoft.strategy.ts index f12c8b842..9ee913bf2 100644 --- a/packages/apollo-collaboration-server/src/utils/strategies/microsoft.strategy.ts +++ b/packages/apollo-collaboration-server/src/utils/strategies/microsoft.strategy.ts @@ -8,6 +8,10 @@ import { Strategy } from 'passport-microsoft' import { AuthenticationService } from '../../authentication/authentication.service.js' +function isRemoteAuthEnabled(value?: string): boolean { + return Boolean(value) && value !== 'disabled' +} + export interface Profile { provider: 'microsoft' name: { familyName: string; givenName: string } @@ -42,7 +46,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) { }) clientID = clientIDFile && fs.readFileSync(clientIDFile, 'utf8').trim() } - const configured = Boolean(clientID) + const configured = isRemoteAuthEnabled(clientID) if (!configured) { clientID = 'none' } diff --git a/packages/apollo-schemas/src/assembly.schema.ts b/packages/apollo-schemas/src/assembly.schema.ts index cdce3d3da..c37de0b78 100644 --- a/packages/apollo-schemas/src/assembly.schema.ts +++ b/packages/apollo-schemas/src/assembly.schema.ts @@ -17,6 +17,9 @@ export class Assembly { @Prop() displayName: string + @Prop() + scientificName: string + @Prop({ type: [String] }) aliases: string[] diff --git a/packages/apollo-schemas/src/assemblyPermission.schema.ts b/packages/apollo-schemas/src/assemblyPermission.schema.ts new file mode 100644 index 000000000..46bf5f290 --- /dev/null +++ b/packages/apollo-schemas/src/assemblyPermission.schema.ts @@ -0,0 +1,51 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { + type HydratedDocument, + Schema as MongooseSchema, + type Types, +} from 'mongoose' + +export type AssemblyPermissionDocument = HydratedDocument + +@Schema({ timestamps: true }) +export class AssemblyPermission { + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }) + userId: Types.ObjectId + + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'Assembly', + required: true, + index: true, + }) + assemblyId: Types.ObjectId + + @Prop({ required: true, default: false }) + canViewAnnotations: boolean + + @Prop({ required: true, default: false }) + canEditAnnotations: boolean + + @Prop() + createdBy?: string + + @Prop() + updatedBy?: string +} + +export const AssemblyPermissionSchema = + SchemaFactory.createForClass(AssemblyPermission) + +AssemblyPermissionSchema.index({ userId: 1, assemblyId: 1 }, { unique: true }) + +AssemblyPermissionSchema.pre('validate', function onValidate(next) { + if (this.canEditAnnotations) { + this.canViewAnnotations = true + } + next() +}) diff --git a/packages/apollo-schemas/src/group.schema.ts b/packages/apollo-schemas/src/group.schema.ts new file mode 100644 index 000000000..c20fa7cea --- /dev/null +++ b/packages/apollo-schemas/src/group.schema.ts @@ -0,0 +1,21 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import type { HydratedDocument } from 'mongoose' + +export type GroupDocument = HydratedDocument + +@Schema({ timestamps: true }) +export class Group { + @Prop({ required: true, unique: true, trim: true }) + name: string + + @Prop({ trim: true }) + description?: string + + @Prop() + createdBy?: string + + @Prop() + updatedBy?: string +} + +export const GroupSchema = SchemaFactory.createForClass(Group) diff --git a/packages/apollo-schemas/src/groupAssemblyPermission.schema.ts b/packages/apollo-schemas/src/groupAssemblyPermission.schema.ts new file mode 100644 index 000000000..b437789b3 --- /dev/null +++ b/packages/apollo-schemas/src/groupAssemblyPermission.schema.ts @@ -0,0 +1,56 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { + type HydratedDocument, + Schema as MongooseSchema, + type Types, +} from 'mongoose' + +export type GroupAssemblyPermissionDocument = + HydratedDocument + +@Schema({ timestamps: true }) +export class GroupAssemblyPermission { + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'Group', + required: true, + index: true, + }) + groupId: Types.ObjectId + + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'Assembly', + required: true, + index: true, + }) + assemblyId: Types.ObjectId + + @Prop({ required: true, default: false }) + canViewAnnotations: boolean + + @Prop({ required: true, default: false }) + canEditAnnotations: boolean + + @Prop() + createdBy?: string + + @Prop() + updatedBy?: string +} + +export const GroupAssemblyPermissionSchema = SchemaFactory.createForClass( + GroupAssemblyPermission, +) + +GroupAssemblyPermissionSchema.index( + { groupId: 1, assemblyId: 1 }, + { unique: true }, +) + +GroupAssemblyPermissionSchema.pre('validate', function onValidate(next) { + if (this.canEditAnnotations) { + this.canViewAnnotations = true + } + next() +}) diff --git a/packages/apollo-schemas/src/groupMembership.schema.ts b/packages/apollo-schemas/src/groupMembership.schema.ts new file mode 100644 index 000000000..b8eda8492 --- /dev/null +++ b/packages/apollo-schemas/src/groupMembership.schema.ts @@ -0,0 +1,38 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { + type HydratedDocument, + Schema as MongooseSchema, + type Types, +} from 'mongoose' + +export type GroupMembershipDocument = HydratedDocument + +@Schema({ timestamps: true }) +export class GroupMembership { + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'Group', + required: true, + index: true, + }) + groupId: Types.ObjectId + + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }) + userId: Types.ObjectId + + @Prop() + createdBy?: string + + @Prop() + updatedBy?: string +} + +export const GroupMembershipSchema = + SchemaFactory.createForClass(GroupMembership) + +GroupMembershipSchema.index({ groupId: 1, userId: 1 }, { unique: true }) diff --git a/packages/apollo-schemas/src/index.ts b/packages/apollo-schemas/src/index.ts index 139d9606f..c2222116b 100644 --- a/packages/apollo-schemas/src/index.ts +++ b/packages/apollo-schemas/src/index.ts @@ -1,4 +1,5 @@ export * from './assembly.schema.js' +export * from './assemblyPermission.schema.js' export * from './change.schema.js' export * from './check.schema.js' export * from './checkResult.schema.js' @@ -6,6 +7,9 @@ export * from './counter.schema.js' export * from './export.schema.js' export * from './feature.schema.js' export * from './file.schema.js' +export * from './group.schema.js' +export * from './groupMembership.schema.js' +export * from './groupAssemblyPermission.schema.js' export * from './refSeq.schema.js' export * from './refSeqChunk.schema.js' export * from './user.schema.js' diff --git a/packages/apollo-schemas/src/user.schema.ts b/packages/apollo-schemas/src/user.schema.ts index 45e53ca93..5341f4da4 100644 --- a/packages/apollo-schemas/src/user.schema.ts +++ b/packages/apollo-schemas/src/user.schema.ts @@ -13,6 +13,9 @@ export class User { @Prop({ required: true, unique: true }) email: string + @Prop({ select: false }) + passwordHash?: string + @Prop({ type: String, enum: ['readOnly', 'admin', 'user', 'none'] }) role: Role } diff --git a/packages/apollo-shared/src/Changes/AddAssemblyAndFeaturesFromFileChange.ts b/packages/apollo-shared/src/Changes/AddAssemblyAndFeaturesFromFileChange.ts index 4b7380266..e989a5887 100644 --- a/packages/apollo-shared/src/Changes/AddAssemblyAndFeaturesFromFileChange.ts +++ b/packages/apollo-shared/src/Changes/AddAssemblyAndFeaturesFromFileChange.ts @@ -11,6 +11,7 @@ export interface SerializedAddAssemblyAndFeaturesFromFileChangeBase export interface AddAssemblyAndFeaturesFromFileChangeDetails { assemblyName: string + scientificName?: string fileIds: { fa: string } parseOptions?: { bufferSize?: number; strict?: boolean } } @@ -47,8 +48,8 @@ export class AddAssemblyAndFeaturesFromFileChange extends AssemblySpecificChange toJSON(): SerializedAddAssemblyAndFeaturesFromFileChange { const { assembly, changes, typeName } = this if (changes.length === 1) { - const [{ assemblyName, fileIds }] = changes - return { typeName, assembly, assemblyName, fileIds } + const [{ assemblyName, scientificName, fileIds }] = changes + return { typeName, assembly, assemblyName, scientificName, fileIds } } return { typeName, assembly, changes } } diff --git a/packages/apollo-shared/src/Changes/AddAssemblyFromExternalChange.ts b/packages/apollo-shared/src/Changes/AddAssemblyFromExternalChange.ts index 55bbdc968..86e38598c 100644 --- a/packages/apollo-shared/src/Changes/AddAssemblyFromExternalChange.ts +++ b/packages/apollo-shared/src/Changes/AddAssemblyFromExternalChange.ts @@ -11,6 +11,7 @@ export interface SerializedAddAssemblyFromExternalChangeBase export interface AddAssemblyFromExternalChangeDetails { assemblyName: string + scientificName?: string externalLocation: { fa: string; fai: string; gzi?: string } } @@ -46,8 +47,14 @@ export class AddAssemblyFromExternalChange extends AssemblySpecificChange { toJSON(): SerializedAddAssemblyFromExternalChange { const { assembly, changes, typeName } = this if (changes.length === 1) { - const [{ assemblyName, externalLocation }] = changes - return { typeName, assembly, assemblyName, externalLocation } + const [{ assemblyName, scientificName, externalLocation }] = changes + return { + typeName, + assembly, + assemblyName, + scientificName, + externalLocation, + } } return { typeName, assembly, changes } } diff --git a/packages/apollo-shared/src/Changes/AddAssemblyFromFileChange.ts b/packages/apollo-shared/src/Changes/AddAssemblyFromFileChange.ts index 8a5409ed0..66293a4c8 100644 --- a/packages/apollo-shared/src/Changes/AddAssemblyFromFileChange.ts +++ b/packages/apollo-shared/src/Changes/AddAssemblyFromFileChange.ts @@ -11,6 +11,7 @@ export interface SerializedAddAssemblyFromFileChangeBase export interface AddAssemblyFromFileChangeDetails { assemblyName: string + scientificName?: string fileIds: { fa: string } | { fa: string; fai: string; gzi: string } } @@ -46,8 +47,8 @@ export class AddAssemblyFromFileChange extends AssemblySpecificChange { toJSON(): SerializedAddAssemblyFromFileChange { const { assembly, changes, typeName } = this if (changes.length === 1) { - const [{ assemblyName, fileIds }] = changes - return { typeName, assembly, assemblyName, fileIds } + const [{ assemblyName, scientificName, fileIds }] = changes + return { typeName, assembly, assemblyName, scientificName, fileIds } } return { typeName, assembly, changes } } diff --git a/packages/jbrowse-plugin-apollo/README.md b/packages/jbrowse-plugin-apollo/README.md index 474c9b56f..135687fe7 100644 --- a/packages/jbrowse-plugin-apollo/README.md +++ b/packages/jbrowse-plugin-apollo/README.md @@ -1,5 +1,63 @@ # jbrowse-plugin-apollo +## Current auth UX behavior (2026-06-09) + +- Local login now validates credentials before closing the dialog. +- Invalid credentials show an in-dialog error + (`Invalid username/email or password`). +- While local auth is in flight, the local sign-in button is disabled and shows + `Signing in...`. +- Top-level Apollo menu includes a signed-in indicator when a token is present. + +## Quick local auth verification + +1. Open JBrowse and select Apollo login. +2. Attempt local login with an incorrect password. +3. Confirm the dialog remains open and shows an explicit error. +4. Retry with valid credentials. +5. Confirm login succeeds and menu label updates to signed-in identity. + +## Auth and access behavior updates (2026-06-11) + +- Successful Apollo login now revokes tokens from other Apollo internet accounts + in the same session. +- Apollo top-level menu now has explicit `Log in` and `Log out` actions. +- Log out now transitions directly to guest auth without a full page reload. +- Guest users are normalized to `readOnly` role and cannot submit changes. + +## Permission management updates (2026-06-11) + +- `Manage users -> Effective access` now refreshes immediately after: + - direct user assembly permission changes, + - user group membership changes, + - group membership changes affecting the currently selected user, + - group assembly permission changes when the selected user is a member. +- `My workspace` now supports: + - `Replace` to reset open views and load the selected assembly in one fresh + view, + - `Add view` to open the selected assembly in an additional view. + +## Unit test runtime (Node) + +- Apollo plugin Jest tooling is not compatible with Node 26+. +- The package `test` and `test:ci` scripts now run a Node version check first. +- On unsupported versions, the command fails fast with: + `Apollo plugin Jest tooling is not compatible with Node . Use Node 20 or 22.` + +Run plugin tests with Node 22 (without changing your global Node): + +```bash +cd repos/Apollo3/packages/jbrowse-plugin-apollo +JEST_BIN=$(npx -y node@22 $(which yarn) bin jest) +npx -y node@22 $(which yarn) node --experimental-vm-modules "$JEST_BIN" src/ApolloInternetAccount/tokenUtils.test.ts +``` + +If you have Node 20 or 22 active in your shell, the normal command also works: + +```bash +yarn --cwd repos/Apollo3/packages/jbrowse-plugin-apollo test +``` + ## Testing with cypress These notes setup cypress and run tests. These notes are likely to change. diff --git a/packages/jbrowse-plugin-apollo/package.json b/packages/jbrowse-plugin-apollo/package.json index bcffe93c4..a711e3f71 100644 --- a/packages/jbrowse-plugin-apollo/package.json +++ b/packages/jbrowse-plugin-apollo/package.json @@ -19,15 +19,16 @@ "src" ], "scripts": { + "check:node": "node -e \"const major=Number(process.versions.node.split('.')[0]); if (major>=26){console.error('Apollo plugin Jest tooling is not compatible with Node '+process.versions.node+'. Use Node 20 or 22.'); process.exit(1)} console.log('Node version OK for plugin tests:', process.versions.node)\"", "setup": "rimraf .jbrowse && jbrowse create .jbrowse", - "clean": "rimraf dist", + "clean": "mkdir -p dist && find dist -mindepth 1 -maxdepth 1 -exec rm -rf {} +", "start": "yarn clean && npm-run-all --parallel start:watch start:server", "start:watch": "JB_NPM=false NODE_ENV=development rollup --config rollup/rollup.config.mjs --watch", "start:server": "serve --no-request-logging --cors --listen 9000 --no-port-switching .", "build": "yarn build:shared && yarn clean && rollup --config rollup/rollup.config.mjs", "browse": "serve --no-request-logging --listen 8999 --no-port-switching --symlinks .jbrowse", - "test": "yarn node --experimental-vm-modules $(yarn bin jest)", - "test:ci": "yarn node --experimental-vm-modules $(yarn bin jest) --coverage", + "test": "yarn check:node && yarn node --experimental-vm-modules $(yarn bin jest)", + "test:ci": "yarn check:node && yarn node --experimental-vm-modules $(yarn bin jest) --coverage", "start:collab-cypress": "yarn workspace @apollo-annotation/collaboration-server run cypress:start", "test:e2e": "yarn build && start-test \"npm-run-all --parallel start:server browse start:collab-cypress\" \"9000|8999|http://localhost:3999/health\" \"npm-run-all cypress:run\"", "test:e2e:debug": "yarn build && start-test \"npm-run-all --parallel start:server browse start:collab-cypress\" \"9000|8999|http://localhost:3999/health\" \"npm-run-all cypress:debug\"", @@ -108,7 +109,7 @@ "fake-indexeddb": "^4.0.2", "jest": "^29.6.2", "jest-fetch-mock": "^3.0.3", - "jest-util": "^30.2.0", + "jest-util": "^29.7.0", "librpc-web-mod": "^1.1.9", "lodash.merge": "^4.6.2", "mobx": "^6.6.1", diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx index 613eeb586..8b9f66813 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx @@ -2,18 +2,25 @@ import { isAbortException } from '@jbrowse/core/util/aborting' import { makeStyles } from '@jbrowse/core/util/tss-react' import { + Box, Button, DialogActions, DialogContent, DialogContentText, Divider, + TextField, } from '@mui/material' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Dialog } from '../../components/Dialog' import { createFetchErrorMessage } from '../../util' -import { GoogleButton, GuestButton, MicrosoftButton } from './LoginButtons' +import { + GoogleButton, + GuestButton, + LoginGovButton, + MicrosoftButton, +} from './LoginButtons' const useStyles = makeStyles()((theme) => ({ divider: { @@ -26,14 +33,28 @@ export const AuthTypeSelector = ({ baseURL, handleClose, name, + preferGuest, }: { baseURL: string name: string - handleClose: (type?: 'google' | 'microsoft' | 'guest' | Error) => void + preferGuest?: boolean + handleClose: ( + type?: + | 'google' + | 'microsoft' + | 'logingov' + | 'guest' + | { type: 'local'; identifier: string; password: string } + | Error, + ) => void }) => { const { classes } = useStyles() const [errorMessage, setErrorMessage] = useState('') const [loginTypes, setLoginTypes] = useState([]) + const [identifier, setIdentifier] = useState('') + const [password, setPassword] = useState('') + const [isSubmittingLocal, setIsSubmittingLocal] = useState(false) + const didAutoSelectGuest = useRef(false) useEffect(() => { const controller = new AbortController() const { signal } = controller @@ -50,6 +71,14 @@ export const AuthTypeSelector = ({ } const data = (await response.json()) as string[] setLoginTypes(data) + if ( + preferGuest && + data.includes('guest') && + !didAutoSelectGuest.current + ) { + didAutoSelectGuest.current = true + handleClose('guest') + } } getAuthTypes().catch((error) => { if (!isAbortException(error)) { @@ -64,20 +93,73 @@ export const AuthTypeSelector = ({ ), ) } - }, [baseURL]) + }, [baseURL, handleClose, preferGuest]) - function handleClick(authType: 'google' | 'microsoft' | 'guest') { - if (authType === 'google') { - handleClose('google') - } else if (authType === 'microsoft') { - handleClose('microsoft') - } else { - handleClose('guest') + function handleClick( + authType: 'google' | 'microsoft' | 'logingov' | 'guest', + ) { + switch (authType) { + case 'google': { + handleClose('google') + break + } + case 'microsoft': { + handleClose('microsoft') + break + } + case 'logingov': { + handleClose('logingov') + break + } + default: { + handleClose('guest') + } + } + } + + async function submitLocalLogin() { + if (!identifier || !password) { + setErrorMessage('Local login requires username/email and password') + return + } + + setIsSubmittingLocal(true) + setErrorMessage('') + try { + const uri = new URL('auth/local', baseURL).href + const response = await fetch(uri, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ identifier, password }), + }) + + if (!response.ok) { + if (response.status === 401) { + setErrorMessage('Invalid username/email or password') + return + } + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when logging in locally', + ) + setErrorMessage(newErrorMessage) + return + } + + handleClose({ type: 'local', identifier, password }) + } catch (error) { + setErrorMessage(String(error)) + } finally { + setIsSubmittingLocal(false) } } const allowGoogle = loginTypes.includes('google') const allowMicrosoft = loginTypes.includes('microsoft') + const allowLoginGov = loginTypes.includes('logingov') + const allowLocal = loginTypes.includes('local') const allowGuest = loginTypes.includes('guest') return ( ) : null} + {allowLoginGov ? ( + { + handleClick('logingov') + }} + /> + ) : null} + {allowLocal ? ( + <> + + + { + setIdentifier(event.target.value) + }} + /> + { + setPassword(event.target.value) + }} + /> + + + + ) : null} {allowGuest ? ( <> diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/LoginButtons.tsx b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/LoginButtons.tsx index 752f64b4d..77eb12641 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/LoginButtons.tsx +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/LoginButtons.tsx @@ -47,6 +47,20 @@ export function MicrosoftButton(props: ButtonProps) { ) } +export function LoginGovButton(props: ButtonProps) { + const { classes } = useStyles() + return ( + + ) +} + export function GuestButton(props: ButtonProps) { const { classes } = useStyles() return ( diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts index 2909a8c43..14ca853c5 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-call */ @@ -35,16 +34,42 @@ import { io } from 'socket.io-client' import { addTopLevelAdminMenus } from '../menus/topLevelMenuAdmin' import type { Collaborator } from '../session' -import type { ApolloRootModel } from '../types' +import { type ApolloRootModel, isApolloInternetAccount } from '../types' import { createFetchErrorMessage } from '../util' import { AuthTypeSelector } from './components/AuthTypeSelector' import type { ApolloInternetAccountConfigModel } from './configSchema' +import { revokeOtherApolloTokens } from './tokenUtils' -type AuthType = 'google' | 'microsoft' | 'guest' +type AuthType = 'google' | 'microsoft' | 'logingov' | 'guest' +interface LocalAuthSelection { + type: 'local' + identifier: string + password: string +} +type AuthSelection = AuthType | LocalAuthSelection + +interface LiveApolloSession extends AbstractSessionModel { + notify(message: string, level?: string): void + broadcastLocations(): void + addOrUpdateCollaborator(collaborator: Collaborator): void + apolloDataStore: { + addCheckResult(checkResult: unknown): void + deleteCheckResult(id: string): void + changeManager: { + submit(change: Change, options?: { submitToBackend?: boolean }): unknown + } + } +} type Role = 'admin' | 'user' | 'readOnly' | 'none' +function isGuestIdentity(decodedToken: { username?: string; email?: string }) { + const normalizedUsername = String(decodedToken.username).toLowerCase() + const normalizedEmail = String(decodedToken.email).toLowerCase() + return normalizedUsername === 'guest' || normalizedEmail === 'guest_user' +} + const inWebWorker = typeof sessionStorage === 'undefined' const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { @@ -55,7 +80,17 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { }) .views((self) => ({ get baseURL(): string { - return getConf(self, 'baseURL') + const configuredBaseURL = String(getConf(self, 'baseURL') ?? '') + if (!configuredBaseURL) { + return globalThis.location.origin || 'http://localhost' + } + try { + const fallbackBase = globalThis.location.origin || 'http://localhost' + const resolved = new URL(configuredBaseURL, fallbackBase).href + return resolved.endsWith('/') ? resolved : `${resolved}/` + } catch { + return configuredBaseURL + } }, getUserId() { const token = self.retrieveToken() @@ -85,8 +120,11 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { self.removeToken() return } - if (self.role !== role) { - self.role = role + const normalizedRole: Role = isGuestIdentity(dec) + ? 'readOnly' + : role ?? 'none' + if (self.role !== normalizedRole) { + self.role = normalizedRole } }, })) @@ -97,7 +135,8 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { const fetcher = superGetFetcher(loc) return async (input: RequestInfo, init?: RequestInit) => { const response = await fetcher(input, init) - if (response.status === 403) { + // Only invalidate local token on explicit auth failure. + if (response.status === 401) { self.removeToken() self.setRole() return fetcher(input, init) @@ -112,6 +151,23 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { sessionStorage.removeItem(self.tokenKey) self.tokenPromise = undefined }, + removeOtherApolloTokens() { + let rootModel: ApolloRootModel + try { + rootModel = getRoot(self) + } catch { + return + } + const apolloAccounts = rootModel.internetAccounts.filter((account) => + isApolloInternetAccount(account), + ) + revokeOtherApolloTokens(apolloAccounts, self.tokenKey) + }, + applyLoggedInToken(token: string) { + this.removeOtherApolloTokens() + self.storeToken(token) + self.setRole() + }, })) .actions((self) => ({ async getToken(location?: UriLocation): Promise { @@ -136,7 +192,7 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { self.tokenPromise = new Promise((resolve, reject) => { self.getTokenFromUser( (token) => { - self.storeToken(token) + self.applyLoggedInToken(token) resolve(token) }, (error) => { @@ -184,8 +240,7 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { reject(new Error('Error with token endpoint')) return } - self.storeToken(token) - self.setRole() + self.applyLoggedInToken(token) resolve(token) }, async openAuthWindow( @@ -225,21 +280,38 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { } }) .actions((self) => ({ - async getTokenFromUser( + async getTokenFromUserWithPreference( resolve: (token: string) => void, reject: (error: Error) => void, + preferGuest: boolean, ): Promise { const { baseURL } = self const authType = await new Promise( - (resolve: (authType: AuthType) => void, reject) => { - const { session } = getRoot(self) + (resolve: (authType: AuthSelection) => void, reject) => { + if (!isAlive(self)) { + reject(new Error('Apollo session is no longer available')) + return + } + let session: ApolloRootModel['session'] | undefined + try { + const { session: rootSession } = getRoot(self) + session = rootSession + } catch { + reject(new Error('No active session available for login dialog')) + return + } + if (!isAlive(session)) { + reject(new Error('No active session available for login dialog')) + return + } const { baseURL, name } = self ;(session as unknown as AbstractSessionModel).queueDialog( (doneCallback: () => void) => [ AuthTypeSelector, { name, - handleClose: (newAuthType?: AuthType | Error) => { + preferGuest, + handleClose: (newAuthType?: AuthSelection | Error) => { if (!newAuthType) { reject(new Error('user cancelled entry')) } else if (newAuthType instanceof Error) { @@ -255,13 +327,37 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { ) }, ) - if (authType !== 'guest') { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - self.openAuthWindow(authType, resolve, reject) + if (typeof authType !== 'string') { + const uri = new URL('auth/local', baseURL).href + const response = await fetch(uri, { + method: 'POST', + signal: self.controller.signal, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identifier: authType.identifier, + password: authType.password, + }), + }) + if (!response.ok) { + const errorMessage = await createFetchErrorMessage( + response, + 'Error when logging in locally', + ) + reject(new Error(errorMessage)) + return + } + const { token } = await response.json() + resolve(token) + return + } + if (typeof authType === 'string' && authType !== 'guest') { + void self.openAuthWindow(authType, resolve, reject) return } const url = new URL('auth/login', baseURL) - const searchParams = new URLSearchParams({ type: authType }) + const searchParams = new URLSearchParams({ type: 'guest' }) url.search = searchParams.toString() const uri = url.toString() const response = await fetch(uri, { signal: self.controller.signal }) @@ -276,6 +372,27 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { const { token } = await response.json() resolve(token) }, + async getTokenFromUser( + resolve: (token: string) => void, + reject: (error: Error) => void, + ): Promise { + return this.getTokenFromUserWithPreference(resolve, reject, true) + }, + async loginWithPrompt(): Promise { + const token = await new Promise((resolve, reject) => { + void this.getTokenFromUserWithPreference( + (newToken: string) => { + self.applyLoggedInToken(newToken) + resolve(newToken) + }, + (error: Error) => { + reject(error) + }, + false, + ) + }) + return token + }, })) .volatile(() => ({ lastChangeSequenceNumber: undefined as number | undefined, @@ -323,7 +440,19 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { }, ), getMissingChanges: flow(function* getMissingChanges() { - const { session } = getRoot(self) + if (!isAlive(self)) { + return + } + let session: ApolloRootModel['session'] | undefined + try { + const { session: rootSession } = getRoot(self) + session = rootSession + } catch { + return + } + if (!isAlive(session)) { + return + } const { changeManager } = session.apolloDataStore if (!self.lastChangeSequenceNumber) { throw new Error( @@ -373,68 +502,103 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { const { origin, pathname: path } = new URL('socket.io/', self.baseURL) return { socket: io(origin, { path }) } }) - .actions((self) => ({ - addSocketListeners() { - const { session } = getRoot(self) - const { notify } = session as unknown as AbstractSessionModel - const token = self.retrieveToken() - if (!token) { - throw new Error('No Token found') + .actions((self) => { + function getLiveSession() { + if (!isAlive(self)) { + return } - const user = getDecodedToken(token) - const localSessionId = makeUserSessionId(user) - const { socket } = self - const { addCheckResult, changeManager, deleteCheckResult } = - session.apolloDataStore - socket.on('connect', () => { - void self.getMissingChanges() - }) - socket.on('connect_error', (error) => { - console.error(error) - notify('Could not connect to the Apollo server.', 'error') - }) - socket.on('COMMON', (message: ChangeMessage | CheckResultUpdate) => { - if ('checkResult' in message) { - if (message.deleted) { - deleteCheckResult(message.checkResult._id.toString()) - } else { - addCheckResult(message.checkResult) - } - return - } - // Save server last change sequence into session storage - sessionStorage.setItem( - 'LastChangeSequence', - String(message.changeSequence), - ) - if (message.userSessionId === localSessionId) { - return // we did this change, no need to apply it again + try { + const { session } = getRoot(self) + return isAlive(session) + ? (session as unknown as LiveApolloSession) + : undefined + } catch { + return + } + } + + return { + addSocketListeners() { + const token = self.retrieveToken() + if (!token) { + throw new Error('No Token found') } - const change = Change.fromJSON(message.changeInfo) - void changeManager.submit(change, { submitToBackend: false }) - }) - socket.on('USER_LOCATION', (message: UserLocationMessage) => { - const { channel, locations, userName, userSessionId } = message - if (channel === 'USER_LOCATION' && userSessionId !== localSessionId) { - const collaborator: Collaborator = { - name: userName, - id: userSessionId, - locations, + const user = getDecodedToken(token) + const localSessionId = makeUserSessionId(user) + const { socket } = self + socket.on('connect', () => { + void self.getMissingChanges() + }) + socket.on('connect_error', (error) => { + console.error(error) + getLiveSession()?.notify( + 'Could not connect to the Apollo server.', + 'error', + ) + }) + socket.on('COMMON', (message: ChangeMessage | CheckResultUpdate) => { + const session = getLiveSession() + if (!session) { + return } - session.addOrUpdateCollaborator(collaborator) - } - }) - socket.on( - 'REQUEST_INFORMATION', - (message: RequestUserInformationMessage) => { - const { channel, userSessionId } = message - if (channel === 'REQUEST_INFORMATION' && userSessionId !== token) { - session.broadcastLocations() + const { addCheckResult, changeManager, deleteCheckResult } = + session.apolloDataStore + if ('checkResult' in message) { + if (message.deleted) { + deleteCheckResult(message.checkResult._id.toString()) + } else { + addCheckResult(message.checkResult) + } + return } - }, - ) - }, - })) + // Save server last change sequence into session storage + sessionStorage.setItem( + 'LastChangeSequence', + String(message.changeSequence), + ) + if (message.userSessionId === localSessionId) { + return // we did this change, no need to apply it again + } + const change = Change.fromJSON(message.changeInfo) + void changeManager.submit(change, { submitToBackend: false }) + }) + socket.on('USER_LOCATION', (message: UserLocationMessage) => { + const session = getLiveSession() + if (!session) { + return + } + const { channel, locations, userName, userSessionId } = message + if ( + channel === 'USER_LOCATION' && + userSessionId !== localSessionId + ) { + const collaborator: Collaborator = { + name: userName, + id: userSessionId, + locations, + } + session.addOrUpdateCollaborator(collaborator) + } + }) + socket.on( + 'REQUEST_INFORMATION', + (message: RequestUserInformationMessage) => { + const session = getLiveSession() + if (!session) { + return + } + const { channel, userSessionId } = message + if ( + channel === 'REQUEST_INFORMATION' && + userSessionId !== token + ) { + session.broadcastLocations() + } + }, + ) + }, + } + }) .actions((self) => { async function postUserLocation(userLoc: UserLocation[]) { if (!isAlive(self) || self.role === 'none') { @@ -477,6 +641,20 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { }) .volatile(() => ({ roleNotificationSent: false })) .actions((self) => { + function getLiveSession() { + if (!isAlive(self)) { + return + } + try { + const { session } = getRoot(self) + return isAlive(session) + ? (session as unknown as LiveApolloSession) + : undefined + } catch { + return + } + } + function beforeUnloadListener() { self.postUserLocation([]) } @@ -487,16 +665,17 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { } // fires when app transitions from prerender, user returns to the app / tab. if (document.visibilityState === 'visible') { - const { session } = getRoot(self) - session.broadcastLocations() + getLiveSession()?.broadcastLocations() } } return { initialize: flow(function* initialize(role: Role) { + if (!isAlive(self)) { + return + } if (role === 'none') { if (!self.roleNotificationSent) { - const { session } = getRoot(self) - ;(session as unknown as AbstractSessionModel).notify( + getLiveSession()?.notify( 'You have registered as an Apollo user but have not been given access. Ask your administrator to enable access for your account.', 'warning', ) @@ -505,7 +684,15 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { return } if (role === 'admin') { - const rootModel = getRoot(self) + if (!isAlive(self)) { + return + } + let rootModel: unknown + try { + rootModel = getRoot(self) + } catch { + return + } if (isAbstractMenuManager(rootModel)) { addTopLevelAdminMenus(rootModel) } @@ -521,10 +708,16 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { locationType: 'UriLocation', uri, }) - yield apolloFetch(uri, { - method: 'GET', - signal: self.controller.signal, - }) + try { + yield apolloFetch(uri, { + method: 'GET', + signal: self.controller.signal, + }) + } catch { + if (!self.controller.signal.aborted) { + console.error('Fetching user locations failed') + } + } window.addEventListener('beforeunload', beforeUnloadListener) document.addEventListener( 'visibilitychange', @@ -550,11 +743,7 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { if (inWebWorker) { return } - const { session } = getRoot(self) - // This can be undefined if there is no session loaded, e.g. on - // the start screen - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!session) { + if (!isAlive(self)) { return } if (self.role) { diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.test.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.test.ts new file mode 100644 index 000000000..b46f1d8bc --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, jest } from '@jest/globals' + +import { revokeOtherApolloTokens } from './tokenUtils' + +describe('ApolloInternetAccount token revocation', () => { + it('revokes all other Apollo account tokens and keeps current token', () => { + const keepCurrent = { tokenKey: 'token-current', removeToken: jest.fn() } + const revokeFirst = { tokenKey: 'token-first', removeToken: jest.fn() } + const revokeSecond = { tokenKey: 'token-second', removeToken: jest.fn() } + + revokeOtherApolloTokens( + [keepCurrent, revokeFirst, revokeSecond], + keepCurrent.tokenKey, + ) + + expect(keepCurrent.removeToken).not.toHaveBeenCalled() + expect(revokeFirst.removeToken).toHaveBeenCalledTimes(1) + expect(revokeSecond.removeToken).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts new file mode 100644 index 000000000..f3e008eed --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts @@ -0,0 +1,15 @@ +export interface ApolloTokenAccount { + tokenKey: string + removeToken(): void +} + +export function revokeOtherApolloTokens( + apolloAccounts: ApolloTokenAccount[], + currentTokenKey: string, +) { + for (const internetAccount of apolloAccounts) { + if (internetAccount.tokenKey !== currentTokenKey) { + internetAccount.removeToken() + } + } +} diff --git a/packages/jbrowse-plugin-apollo/src/ChangeManager.ts b/packages/jbrowse-plugin-apollo/src/ChangeManager.ts index 7449811a1..6b0840103 100644 --- a/packages/jbrowse-plugin-apollo/src/ChangeManager.ts +++ b/packages/jbrowse-plugin-apollo/src/ChangeManager.ts @@ -4,6 +4,7 @@ import { } from '@apollo-annotation/common' import { type ValidationResultSet, + getDecodedToken, validationRegistry, } from '@apollo-annotation/shared' import { getSession } from '@jbrowse/core/util' @@ -39,6 +40,46 @@ export class ChangeManager { const session = getSession(this.dataStore) const controller = new AbortController() + const internetAccounts = this.dataStore.internetAccounts as { + type?: string + internetAccountId?: string + role?: string + retrieveToken?: () => string | undefined + }[] + const apolloAccounts = internetAccounts.filter( + (account) => account.type === 'ApolloInternetAccount', + ) + const accountForSubmit = opts.internetAccountId + ? apolloAccounts.find( + (account) => account.internetAccountId === opts.internetAccountId, + ) + : apolloAccounts.find((account) => Boolean(account.retrieveToken?.())) + const token = accountForSubmit?.retrieveToken?.() + if (!token) { + session.notify('Cannot submit changes while signed out') + return + } + + let isGuest = false + let decodedRole: string | undefined + try { + const decoded = getDecodedToken(token) + decodedRole = decoded.role + isGuest = + decoded.username.toLowerCase() === 'guest' || + decoded.email.toLowerCase() === 'guest_user' + } catch { + // ignore decode errors here and fall back to account role below + } + + const effectiveRole = isGuest + ? 'readOnly' + : accountForSubmit?.role ?? decodedRole ?? 'none' + if (!['admin', 'user'].includes(effectiveRole)) { + session.notify('Cannot submit changes while signed in as guest/read-only') + return + } + // eslint-disable-next-line @typescript-eslint/unbound-method const { jobsManager, isLocked, changeInProgress, setChangeInProgress } = getSession(this.dataStore) as unknown as ApolloSessionModel diff --git a/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/stateModel/base.ts b/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/stateModel/base.ts index 7dcf97e83..714d57b4e 100644 --- a/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/stateModel/base.ts +++ b/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/stateModel/base.ts @@ -163,8 +163,12 @@ export function baseModelFactory( return assembly.name }, get selectedFeature(): AnnotationFeature | undefined { - return (self.session as unknown as ApolloSessionModel) - .apolloSelectedFeature + try { + return (self.session as unknown as ApolloSessionModel) + .apolloSelectedFeature + } catch { + return undefined + } }, get hoveredFeature(): HoveredFeature | undefined { return (self.session as unknown as ApolloSessionModel) diff --git a/packages/jbrowse-plugin-apollo/src/LinearApolloSixFrameDisplay/stateModel/base.ts b/packages/jbrowse-plugin-apollo/src/LinearApolloSixFrameDisplay/stateModel/base.ts index 938c4e397..0833a6766 100644 --- a/packages/jbrowse-plugin-apollo/src/LinearApolloSixFrameDisplay/stateModel/base.ts +++ b/packages/jbrowse-plugin-apollo/src/LinearApolloSixFrameDisplay/stateModel/base.ts @@ -154,8 +154,12 @@ export function baseModelFactory( return assembly.name }, get selectedFeature(): AnnotationFeature | undefined { - return (self.session as unknown as ApolloSessionModel) - .apolloSelectedFeature + try { + return (self.session as unknown as ApolloSessionModel) + .apolloSelectedFeature + } catch { + return undefined + } }, get hoveredFeature(): HoveredFeature | undefined { return (self.session as unknown as ApolloSessionModel) diff --git a/packages/jbrowse-plugin-apollo/src/components/AddAssembly.tsx b/packages/jbrowse-plugin-apollo/src/components/AddAssembly.tsx index 6cbca7833..daacceb2f 100644 --- a/packages/jbrowse-plugin-apollo/src/components/AddAssembly.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/AddAssembly.tsx @@ -134,6 +134,7 @@ export function AddAssembly({ throw new Error('No Apollo internet account found') } const [assemblyName, setAssemblyName] = useState('') + const [organismName, setOrganismName] = useState('') const [errorMessage, setErrorMessage] = useState('') const [validAsm, setValidAsm] = useState(false) const [fileType, setFileType] = useState(FileType.BGZIP_FASTA) @@ -249,12 +250,14 @@ export function AddAssembly({ | AddAssemblyFromExternalChange | AddAssemblyAndFeaturesFromFileChange | AddAssemblyFromFileChange + const scientificName = organismName.trim() || undefined if (fileType === FileType.EXTERNAL) { change = new AddAssemblyFromExternalChange({ typeName: 'AddAssemblyFromExternalChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, externalLocation: { fa: fastaUrl, fai: fastaIndexUrl, @@ -271,6 +274,7 @@ export function AddAssembly({ typeName: 'AddAssemblyAndFeaturesFromFileChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, fileIds: { fa: faId }, parseOptions: { strict }, }) @@ -280,6 +284,7 @@ export function AddAssembly({ typeName: 'AddAssemblyFromFileChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, fileIds: { fa: faId, }, @@ -290,6 +295,7 @@ export function AddAssembly({ typeName: 'AddAssemblyFromFileChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, fileIds: { fa: faId, }, @@ -306,6 +312,7 @@ export function AddAssembly({ typeName: 'AddAssemblyFromFileChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, fileIds: { fa: faId, fai: faiId, @@ -387,6 +394,21 @@ export function AddAssembly({ disabled={submitted && !errorMessage} /> + { + setOrganismName(e.target.value) + }} + disabled={submitted && !errorMessage} + /> + (session) + const apolloInternetAccounts = internetAccounts + .filter((ia) => ia.type === 'ApolloInternetAccount') + .filter((ia) => (ia as ApolloInternetAccountModel & { role?: string }).role) + .filter((ia) => + ( + (ia as ApolloInternetAccountModel & { role?: string }).role ?? '' + ).includes('admin'), + ) as ApolloInternetAccountModel[] + + if (apolloInternetAccounts.length === 0) { + throw new Error('No Apollo admin internet account found') + } + + const [selectedInternetAccount, setSelectedInternetAccount] = useState( + apolloInternetAccounts[0], + ) + const [assemblies, setAssemblies] = useState([]) + const [selectedAssemblyId, setSelectedAssemblyId] = useState('') + const [displayName, setDisplayName] = useState('') + const [organismName, setOrganismName] = useState('') + const [errorMessage, setErrorMessage] = useState('') + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + async function loadAssemblies() { + const { baseURL } = selectedInternetAccount + const uri = new URL('assemblies', baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when loading assemblies', + ) + throw new Error(newErrorMessage) + } + + const data = (await response.json()) as AssemblyResponse[] + const sorted = [...data].sort((a, b) => a.name.localeCompare(b.name)) + setAssemblies(sorted) + if (sorted.length > 0) { + const [first] = sorted + setSelectedAssemblyId(first._id) + setDisplayName(first.displayName ?? first.name) + setOrganismName(first.scientificName ?? '') + } else { + setSelectedAssemblyId('') + setDisplayName('') + setOrganismName('') + } + } + + loadAssemblies().catch((error: unknown) => { + setErrorMessage(String(error)) + }) + }, [selectedInternetAccount]) + + const selectedAssembly = useMemo( + () => assemblies.find((assembly) => assembly._id === selectedAssemblyId), + [assemblies, selectedAssemblyId], + ) + + function handleChangeInternetAccount(e: SelectChangeEvent) { + const next = apolloInternetAccounts.find( + (ia) => ia.internetAccountId === e.target.value, + ) + if (!next) { + throw new Error( + `Could not find internet account with id "${e.target.value}"`, + ) + } + setSelectedInternetAccount(next) + setErrorMessage('') + } + + function handleChangeAssembly(e: SelectChangeEvent) { + const nextAssembly = assemblies.find((a) => a._id === e.target.value) + setSelectedAssemblyId(e.target.value) + setDisplayName(nextAssembly?.displayName ?? nextAssembly?.name ?? '') + setOrganismName(nextAssembly?.scientificName ?? '') + setErrorMessage('') + } + + async function handleSave() { + if (!selectedAssembly) { + setErrorMessage('Select an assembly first.') + return + } + + setIsSaving(true) + setErrorMessage('') + try { + const { baseURL } = selectedInternetAccount + const uri = new URL(`assemblies/${selectedAssembly._id}`, baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + displayName: displayName.trim() || selectedAssembly.name, + scientificName: organismName.trim() || undefined, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating assembly', + ) + throw new Error(newErrorMessage) + } + + const updated = (await response.json()) as AssemblyResponse + setAssemblies((prev) => + prev.map((assembly) => + assembly._id === updated._id ? { ...assembly, ...updated } : assembly, + ), + ) + setDisplayName(updated.displayName ?? updated.name) + setOrganismName(updated.scientificName ?? '') + } catch (error) { + setErrorMessage(String(error)) + } finally { + setIsSaving(false) + } + } + + return ( + + + {apolloInternetAccounts.length > 1 ? ( + <> + Select account + + + ) : null} + + + Select assembly + + + + + { + setDisplayName(e.target.value) + }} + disabled={!selectedAssembly || isSaving} + fullWidth + /> + + { + setOrganismName(e.target.value) + }} + disabled={!selectedAssembly || isSaving} + fullWidth + /> + + {errorMessage ? ( + {errorMessage} + ) : null} + + + + + + + + + ) +} diff --git a/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx b/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx index d5106997a..b25aaa3a5 100644 --- a/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx @@ -1,5 +1,6 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { getRoot } from '@jbrowse/mobx-state-tree' +import { readConfObject } from '@jbrowse/core/configuration' +import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' +import { isAlive } from '@jbrowse/mobx-state-tree' import { Button, DialogActions, @@ -12,29 +13,95 @@ import { import React, { useState } from 'react' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' -import type { ApolloSessionModel } from '../session' -import type { ApolloRootModel } from '../types' +import { type ApolloRootModel, isApolloInternetAccount } from '../types' import { Dialog } from './Dialog' interface DeleteAssemblyProps { - session: ApolloSessionModel - handleClose(): void + rootModel: ApolloRootModel + handleClose: () => void } -export function LogOut({ handleClose, session }: DeleteAssemblyProps) { - const { internetAccounts } = getRoot(session) - const [errorMessage, setErrorMessage] = useState('') - const apolloInternetAccounts = internetAccounts.filter( - (ia) => ia.type === 'ApolloInternetAccount', - ) as ApolloInternetAccountModel[] - if (apolloInternetAccounts.length === 0) { - throw new Error('No Apollo internet account found') +function isPrivilegedApolloTrack(track: BaseTrackConfig) { + const trackType = (track as unknown as { type?: string }).type + const trackId = readConfObject(track, 'trackId') as string | undefined + const displays = + (readConfObject(track, 'displays') as { type?: string }[] | undefined) ?? [] + const hasApolloDisplay = displays.some((display) => + ['LinearApolloDisplay', 'LinearApolloSixFrameDisplay'].includes( + display.type ?? '', + ), + ) + return ( + trackType === 'ApolloTrack' || + (trackId?.startsWith('apollo_track_') ?? false) || + hasApolloDisplay + ) +} + +function removePrivilegedTracksForGuest(rootModel: ApolloRootModel) { + const session = rootModel.session as unknown as + | { + tracks: BaseTrackConfig[] + views?: unknown[] + deleteTrackConf?: (conf: BaseTrackConfig) => void + apolloSetSelectedFeature?: (feature?: unknown) => void + } + | undefined + if (!session || !isAlive(rootModel.session)) { + return + } + + const jbrowse = rootModel.jbrowse as { + tracks?: BaseTrackConfig[] + deleteTrackConf?: (conf: BaseTrackConfig) => void + } + + const byTrackId = new Map() + const collect = (tracks?: BaseTrackConfig[]) => { + for (const track of tracks ?? []) { + if (!isPrivilegedApolloTrack(track)) { + continue + } + const trackId = readConfObject(track, 'trackId') as string | undefined + byTrackId.set(trackId ?? String(byTrackId.size), track) + } } + + collect(session.tracks) + collect(jbrowse.tracks) + + session.apolloSetSelectedFeature?.() + for (const track of byTrackId.values()) { + session.deleteTrackConf?.(track) + jbrowse.deleteTrackConf?.(track) + } +} + +export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { + const { internetAccounts } = rootModel + const apolloInternetAccounts = internetAccounts.filter((account) => { + try { + if (!isAlive(account)) { + return false + } + return isApolloInternetAccount(account) + } catch { + return false + } + }) as ApolloInternetAccountModel[] + + const [initialSelectedAccount] = apolloInternetAccounts + const [errorMessage, setErrorMessage] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) const [selectedInternetAccount, setSelectedInternetAccount] = useState( - apolloInternetAccounts[0], + initialSelectedAccount, ) + if (apolloInternetAccounts.length === 0) { + return null + } + function handleChangeInternetAccount(e: SelectChangeEvent) { const newlySelectedInternetAccount = apolloInternetAccounts.find( (ia) => ia.internetAccountId === e.target.value, @@ -50,8 +117,34 @@ export function LogOut({ handleClose, session }: DeleteAssemblyProps) { function onSubmit(event: React.FormEvent) { event.preventDefault() setErrorMessage('') - selectedInternetAccount.removeToken() - globalThis.location.reload() + setIsSubmitting(true) + void (async () => { + try { + selectedInternetAccount.removeToken() + const guestLoginUrl = new URL( + 'auth/login', + selectedInternetAccount.baseURL, + ) + guestLoginUrl.search = new URLSearchParams({ type: 'guest' }).toString() + const response = await fetch(guestLoginUrl.toString(), { + method: 'GET', + }) + if (!response.ok) { + throw new Error(`Guest login failed (${response.status})`) + } + const { token } = (await response.json()) as { token?: string } + if (!token) { + throw new Error('Guest login did not return a token') + } + selectedInternetAccount.applyLoggedInToken(token) + removePrivilegedTracksForGuest(rootModel) + handleClose() + } catch { + setErrorMessage('Could not log out from this account. Please retry.') + } finally { + setIsSubmitting(false) + } + })() } return ( @@ -71,7 +164,7 @@ export function LogOut({ handleClose, session }: DeleteAssemblyProps) { value={selectedInternetAccount.internetAccountId} onChange={handleChangeInternetAccount} > - {internetAccounts.map((option) => ( + {apolloInternetAccounts.map((option) => ( {option.name} @@ -85,14 +178,10 @@ export function LogOut({ handleClose, session }: DeleteAssemblyProps) { - - diff --git a/packages/jbrowse-plugin-apollo/src/components/MANAGE_USERS_ASSEMBLY_ACL_NOTES.md b/packages/jbrowse-plugin-apollo/src/components/MANAGE_USERS_ASSEMBLY_ACL_NOTES.md new file mode 100644 index 000000000..c9c4fd5eb --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/MANAGE_USERS_ASSEMBLY_ACL_NOTES.md @@ -0,0 +1,88 @@ +# Manage Users Assembly ACL Notes + +## Purpose + +This document explains the assembly-permission UI that was added to the Manage +Users dialog. + +## Where the logic lives + +- `components/ManageUsers.tsx` +- `components/manageUsersAssemblyPermissions.ts` + +The dialog now has two admin panels: + +- User role management (existing behavior) +- Assembly permission management (new behavior) + +## API usage + +The component calls collaboration server endpoints via the selected Apollo +internet account fetcher. + +Read paths: + +- `GET /users` +- `GET /assemblies` +- `GET /assemblyPermissions/byUser/:userId` + +Write path: + +- `PUT /assemblyPermissions/:userId/:assemblyId` + +## State model in ManageUsers + +- `users`: user list from `/users` +- `assemblies`: assembly list from `/assemblies` +- `selectedUserId`: currently managed user for assembly grants +- `assemblyPermissionsByAssemblyId`: local cache of grant documents keyed by + assembly id + +## Edit behavior + +Each assembly row has two editable booleans: + +- `canViewAnnotations` +- `canEditAnnotations` + +Invariant enforced in UI and backend: + +- `canEditAnnotations=true` implies `canViewAnnotations=true` + +When a row is edited, the component normalizes the payload before sending: + +- if edit is true, view is forced true + +Normalization helper: + +- `normalizeAssemblyPermissionUpdate()` + +Row-building helper: + +- `buildAssemblyPermissionRows()` + +Permission indexing helper: + +- `indexPermissionsByAssemblyId()` + +## Error handling + +All request failures are converted into user-facing messages using +`createFetchErrorMessage` and shown at the bottom of the dialog. + +## Known follow-up + +Current implementation supports one-user-at-a-time grant edits. Bulk grant +actions and assembly filtering are intentionally deferred for a later slice. + +## Unit tests + +Helper-level tests for the new logic are in: + +- `components/manageUsersAssemblyPermissions.test.ts` + +The tests currently cover: + +- assembly permission indexing by assembly id +- edit-implies-view normalization +- assembly row construction defaults when no grant exists diff --git a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx index 6fe7788de..7923ce141 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx @@ -3,31 +3,41 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import { DeleteUserChange, UserChange } from '@apollo-annotation/shared' import { getRoot } from '@jbrowse/mobx-state-tree' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' import DeleteIcon from '@mui/icons-material/Delete' +import HighlightOffIcon from '@mui/icons-material/HighlightOff' import { + Box, Button, + Chip, DialogActions, DialogContent, DialogContentText, + FormControl, + InputLabel, MenuItem, Select, type SelectChangeEvent, + Switch, + Tab, + Tabs, + TextField, + Typography, } from '@mui/material' import { DataGrid, GridActionsCellItem, type GridCellParams, type GridColDef, + type GridRenderCellParams, type GridRowId, type GridRowModel, type GridRowParams, GridToolbar, } from '@mui/x-data-grid' -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' import type { ChangeManager } from '../ChangeManager' @@ -36,6 +46,15 @@ import { type ApolloRootModel, isApolloInternetAccount } from '../types' import { createFetchErrorMessage } from '../util' import { Dialog } from './Dialog' +import { apolloDataGridSx } from './dataGridStyles' +import { + type AssemblyPermissionResponse, + type AssemblyPermissionRow, + type AssemblyResponse, + buildAssemblyPermissionRows, + indexPermissionsByAssemblyId, + normalizeAssemblyPermissionUpdate, +} from './manageUsersAssemblyPermissions' interface UserResponse { _id: string @@ -44,21 +63,131 @@ interface UserResponse { role?: '' | 'admin' | 'user' | 'readOnly' } +interface GroupResponse { + _id: string + name: string + description?: string +} + +interface GroupMembershipResponse { + _id?: string + groupId: string + userId: string +} + +interface GroupAssemblyPermissionResponse { + _id: string + groupId: string + assemblyId: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +interface GroupMembershipRow { + id: string + userId: string + username: string + email: string + isMember: boolean +} + +interface UserGroupMembershipRow { + id: string + groupId: string + groupName: string + genusSpecies: string + description: string + isMember: boolean +} + +interface EffectiveAssemblyPermissionRow { + id: string + assemblyId: string + assemblyName: string + genusSpecies: string + canViewAnnotations: boolean + canEditAnnotations: boolean + source: 'none' | 'direct' | 'group' | 'mixed' +} + +interface EffectiveAssemblyPermissionResponse { + _id: string + userId: string + assemblyId: string + canViewAnnotations: boolean + canEditAnnotations: boolean + source: 'direct' | 'group' | 'mixed' +} + +type ManagementSection = 'users' | 'groups' + +type UserPermissionView = 'effective' | 'assembly' | 'userGroupMemberships' + +type GroupManagementView = 'memberships' | 'permissions' + +type GroupPermissionView = 'current' | 'edit' + +type GroupMembershipView = 'current' | 'edit' + +type UserRoleOption = 'readOnly' | 'user' | 'admin' | 'none' + interface ManageUsersProps { session: ApolloSessionModel handleClose(): void changeManager: ChangeManager } +const manageUsersTabsSx = { + marginBottom: 2, + minHeight: 34, + '& .MuiTabs-flexContainer': { + gap: 1, + flexWrap: 'wrap', + }, + '& .MuiTabs-indicator': { + display: 'none', + }, + '& .MuiTab-root': { + textTransform: 'none', + fontWeight: 600, + fontSize: '0.84rem', + letterSpacing: 0.1, + minHeight: 34, + borderRadius: 2, + border: '1px solid', + borderColor: 'divider', + backgroundColor: 'background.paper', + color: 'text.secondary', + paddingInline: 10, + paddingBlock: 3, + transition: 'all 120ms ease-in-out', + }, + '& .MuiTab-root:hover': { + backgroundColor: 'action.hover', + color: 'text.primary', + }, + '& .MuiTab-root.Mui-selected': { + backgroundColor: 'primary.main', + color: 'primary.contrastText', + borderColor: 'primary.main', + boxShadow: 1, + }, +} + export function ManageUsers({ changeManager, handleClose, session, }: ManageUsersProps) { const { internetAccounts } = getRoot(session) - const apolloInternetAccounts: ApolloInternetAccountModel[] = internetAccounts - .filter((ia) => isApolloInternetAccount(ia)) - .filter((ia) => ia.role?.includes('admin')) + const apolloInternetAccounts = internetAccounts + .filter((ia): ia is ApolloInternetAccountModel => + isApolloInternetAccount(ia), + ) + .filter( + (ia: ApolloInternetAccountModel & { role?: string }) => + ia.role?.includes('admin') ?? false, + ) if (apolloInternetAccounts.length === 0) { throw new Error('No Apollo internet account found') } @@ -67,11 +196,52 @@ export function ManageUsers({ apolloInternetAccounts[0], ) const [users, setUsers] = useState([]) + const [assemblies, setAssemblies] = useState([]) + const [groups, setGroups] = useState([]) + const [groupFilterText, setGroupFilterText] = useState('') + const [selectedUserId, setSelectedUserId] = useState('') + const [selectedGroupId, setSelectedGroupId] = useState('') + const [managementSection, setManagementSection] = + useState('users') + const [permissionView, setPermissionView] = + useState('effective') + const [groupManagementView, setGroupManagementView] = + useState('memberships') + const [groupMembershipView, setGroupMembershipView] = + useState('edit') + const [groupPermissionView, setGroupPermissionView] = + useState('current') + const [assemblyPermissionsByAssemblyId, setAssemblyPermissionsByAssemblyId] = + useState>>({}) + const [ + effectiveAssemblyPermissionsByAssemblyId, + setEffectiveAssemblyPermissionsByAssemblyId, + ] = useState>>({}) + const [groupMembershipByUserId, setGroupMembershipByUserId] = useState< + Partial> + >({}) + const [groupMembershipByGroupId, setGroupMembershipByGroupId] = useState< + Partial> + >({}) + const [groupPermissionsByAssemblyId, setGroupPermissionsByAssemblyId] = + useState>>({}) + const [newGroupName, setNewGroupName] = useState('') + const [newGroupDescription, setNewGroupDescription] = useState('') + const [localUserDialogOpen, setLocalUserDialogOpen] = useState(false) + const [newLocalUsername, setNewLocalUsername] = useState('') + const [newLocalEmail, setNewLocalEmail] = useState('') + const [newLocalPassword, setNewLocalPassword] = useState('') + const [newLocalRole, setNewLocalRole] = useState('user') + const [localUserErrorMessage, setLocalUserErrorMessage] = useState('') + const [isCreatingLocalUser, setIsCreatingLocalUser] = useState(false) - useEffect(() => { - async function getUsers() { + const fetchEffectiveAssemblyPermissionsForUser = useCallback( + async (userId: string) => { const { baseURL } = selectedInternetAccount - const uri = new URL('users', baseURL).href + const uri = new URL( + `assemblyPermissions/effective/byUser/${userId}`, + baseURL, + ).href const apolloFetch = selectedInternetAccount.getFetcher({ locationType: 'UriLocation', uri, @@ -80,19 +250,258 @@ export function ManageUsers({ if (!response.ok) { const newErrorMessage = await createFetchErrorMessage( response, + 'Error when getting effective assembly permissions from db', + ) + throw new Error(newErrorMessage) + } + const permissionData = + (await response.json()) as EffectiveAssemblyPermissionResponse[] + const byAssemblyId: Partial< + Record + > = {} + for (const permission of permissionData) { + byAssemblyId[permission.assemblyId] = permission + } + setEffectiveAssemblyPermissionsByAssemblyId(byAssemblyId) + }, + [selectedInternetAccount], + ) + + useEffect(() => { + async function getUsers() { + const { baseURL } = selectedInternetAccount + const usersUri = new URL('users', baseURL).href + const assembliesUri = new URL('assemblies', baseURL).href + const groupsUri = new URL('assemblyPermissions/groups', baseURL).href + const usersFetcher = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: usersUri, + }) + const assembliesFetcher = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: assembliesUri, + }) + const groupsFetcher = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: groupsUri, + }) + const [usersResponse, assembliesResponse, groupsResponse] = + await Promise.all([ + usersFetcher(usersUri, { method: 'GET' }), + assembliesFetcher(assembliesUri, { method: 'GET' }), + groupsFetcher(groupsUri, { method: 'GET' }), + ]) + if (!usersResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + usersResponse, 'Error when getting user data from db', ) - setErrorMessage(newErrorMessage) - return + throw new Error(newErrorMessage) + } + if (!assembliesResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + assembliesResponse, + 'Error when getting assemblies from db', + ) + throw new Error(newErrorMessage) + } + if (!groupsResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + groupsResponse, + 'Error when getting groups from db', + ) + throw new Error(newErrorMessage) + } + const userData = (await usersResponse.json()) as UserResponse[] + const assemblyData = + (await assembliesResponse.json()) as AssemblyResponse[] + const groupData = (await groupsResponse.json()) as GroupResponse[] + const normalizedUsers: UserResponse[] = userData.map((u) => ({ + ...u, + role: u.role ?? '', + })) + setUsers(normalizedUsers) + setAssemblies(assemblyData) + setGroups(groupData.sort((a, b) => a.name.localeCompare(b.name))) + if (normalizedUsers[0]?._id) { + setSelectedUserId(normalizedUsers[0]._id) + } + if (groupData[0]?._id) { + setSelectedGroupId(groupData[0]._id) + } else { + setSelectedGroupId('') } - const data = (await response.json()) as UserResponse[] - setUsers(data.map((u) => (u.role === undefined ? { ...u, role: '' } : u))) } getUsers().catch((error) => { setErrorMessage(String(error)) }) }, [selectedInternetAccount]) + useEffect(() => { + async function getAssemblyPermissionsForUser() { + if (!selectedUserId) { + setAssemblyPermissionsByAssemblyId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/byUser/${selectedUserId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting assembly permissions from db', + ) + throw new Error(newErrorMessage) + } + const permissionData = + (await response.json()) as AssemblyPermissionResponse[] + setAssemblyPermissionsByAssemblyId( + indexPermissionsByAssemblyId(permissionData), + ) + } + getAssemblyPermissionsForUser().catch((error) => { + setErrorMessage(String(error)) + }) + }, [ + fetchEffectiveAssemblyPermissionsForUser, + selectedInternetAccount, + selectedUserId, + ]) + + useEffect(() => { + async function getEffectiveAssemblyPermissionsForUser() { + if (!selectedUserId) { + setEffectiveAssemblyPermissionsByAssemblyId({}) + return + } + await fetchEffectiveAssemblyPermissionsForUser(selectedUserId) + } + getEffectiveAssemblyPermissionsForUser().catch((error) => { + setErrorMessage(String(error)) + }) + }, [ + fetchEffectiveAssemblyPermissionsForUser, + selectedInternetAccount, + selectedUserId, + ]) + + useEffect(() => { + async function getMembershipsForUser() { + if (!selectedUserId) { + setGroupMembershipByGroupId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/memberships/byUser/${selectedUserId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting user group memberships from db', + ) + throw new Error(newErrorMessage) + } + const membershipData = + (await response.json()) as GroupMembershipResponse[] + const next: Partial> = {} + for (const membership of membershipData) { + next[membership.groupId] = true + } + setGroupMembershipByGroupId(next) + } + getMembershipsForUser().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedInternetAccount, selectedUserId]) + + useEffect(() => { + async function getMembershipsForGroup() { + if (!selectedGroupId) { + setGroupMembershipByUserId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/memberships/byGroup/${selectedGroupId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting group memberships from db', + ) + throw new Error(newErrorMessage) + } + const membershipData = + (await response.json()) as GroupMembershipResponse[] + const next: Partial> = {} + for (const membership of membershipData) { + next[membership.userId] = true + } + setGroupMembershipByUserId(next) + } + getMembershipsForGroup().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedGroupId, selectedInternetAccount]) + + useEffect(() => { + async function getAssemblyPermissionsForGroup() { + if (!selectedGroupId) { + setGroupPermissionsByAssemblyId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/permissions/${selectedGroupId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting group assembly permissions from db', + ) + throw new Error(newErrorMessage) + } + const permissionData = + (await response.json()) as GroupAssemblyPermissionResponse[] + const byAssemblyId: Partial< + Record + > = {} + for (const permission of permissionData) { + byAssemblyId[permission.assemblyId] = permission + } + setGroupPermissionsByAssemblyId(byAssemblyId) + } + getAssemblyPermissionsForGroup().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedGroupId, selectedInternetAccount]) + async function deleteUser(id: GridRowId) { const change = new DeleteUserChange({ typeName: 'DeleteUserChange', @@ -101,7 +510,68 @@ export function ManageUsers({ await changeManager.submit(change, { internetAccountId: selectedInternetAccount.internetAccountId, }) - setUsers((prevUsers) => prevUsers.filter((row) => row._id !== id)) + setUsers((prevUsers: UserResponse[]) => + prevUsers.filter((row: UserResponse) => row._id !== id), + ) + } + + async function createLocalUser() { + const username = newLocalUsername.trim() + const password = newLocalPassword.trim() + const email = newLocalEmail.trim() || username + + if (!username || !password) { + setLocalUserErrorMessage('Username and password are required.') + return + } + + setIsCreatingLocalUser(true) + setLocalUserErrorMessage('') + + try { + const { baseURL } = selectedInternetAccount + const uri = new URL('users/local', baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + email, + password, + role: newLocalRole === 'none' ? undefined : newLocalRole, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when creating local user', + ) + throw new Error(newErrorMessage) + } + + const createdUser = (await response.json()) as UserResponse + setUsers((prevUsers: UserResponse[]) => [...prevUsers, createdUser]) + setSelectedUserId(createdUser._id) + setLocalUserDialogOpen(false) + setNewLocalUsername('') + setNewLocalEmail('') + setNewLocalPassword('') + setNewLocalRole('user') + setLocalUserErrorMessage('') + } catch (error) { + const message = String(error) + setLocalUserErrorMessage(message) + setErrorMessage(message) + } finally { + setIsCreatingLocalUser(false) + } } function isCurrentUser(id: GridRowId) { @@ -121,7 +591,13 @@ export function ManageUsers({ type: 'singleSelect', valueOptions: ['readOnly', 'user', 'admin', 'none'], getOptionLabel(value) { - switch (value) { + let option = '' + if (typeof value === 'string') { + option = value + } else if (typeof value === 'number') { + option = `${value}` + } + switch (option) { case 'readOnly': { return 'Read-only' } @@ -162,7 +638,9 @@ export function ManageUsers({ function handleChangeInternetAccount(e: SelectChangeEvent) { const newlySelectedInternetAccount = apolloInternetAccounts.find( - (ia) => ia.internetAccountId === e.target.value, + (ia) => + (ia as ApolloInternetAccountModel & { internetAccountId: string }) + .internetAccountId === e.target.value, ) if (!newlySelectedInternetAccount) { throw new Error( @@ -172,6 +650,14 @@ export function ManageUsers({ setSelectedInternetAccount(newlySelectedInternetAccount) } + function handleChangeManagedUser(e: SelectChangeEvent) { + setSelectedUserId(e.target.value) + } + + function handleChangeManagedGroup(e: SelectChangeEvent) { + setSelectedGroupId(e.target.value) + } + async function processRowUpdate(newRow: GridRowModel) { const change = new UserChange({ typeName: 'UserChange', @@ -184,6 +670,953 @@ export function ManageUsers({ return newRow } + async function processAssemblyPermissionRowUpdate(newRow: GridRowModel) { + if (!selectedUserId) { + return newRow + } + const { canViewAnnotations, canEditAnnotations } = + normalizeAssemblyPermissionUpdate({ + canViewAnnotations: Boolean(newRow.canViewAnnotations), + canEditAnnotations: Boolean(newRow.canEditAnnotations), + }) + + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/${selectedUserId}/${newRow.assemblyId as string}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + canViewAnnotations, + canEditAnnotations, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating assembly permission', + ) + throw new Error(newErrorMessage) + } + + const savedPermission = + (await response.json()) as AssemblyPermissionResponse + setAssemblyPermissionsByAssemblyId( + (prev: Partial>) => ({ + ...prev, + [savedPermission.assemblyId]: savedPermission, + }), + ) + await fetchEffectiveAssemblyPermissionsForUser(selectedUserId) + + return { + ...newRow, + canViewAnnotations, + canEditAnnotations, + } + } + + async function createGroup() { + if (!newGroupName.trim()) { + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL('assemblyPermissions/groups', baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: newGroupName.trim(), + description: newGroupDescription.trim() || undefined, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when creating group', + ) + throw new Error(newErrorMessage) + } + + const createdGroup = (await response.json()) as GroupResponse + setGroups((prevGroups: GroupResponse[]) => + [...prevGroups, createdGroup].sort((a, b) => + a.name.localeCompare(b.name), + ), + ) + setSelectedGroupId(createdGroup._id) + setNewGroupName('') + setNewGroupDescription('') + } + + async function deleteSelectedGroup() { + if (!selectedGroupId) { + return + } + const groupToDelete = groups.find( + (group: GroupResponse) => group._id === selectedGroupId, + ) + if (!groupToDelete) { + return + } + if (!globalThis.confirm(`Delete group "${groupToDelete.name}"?`)) { + return + } + + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/${selectedGroupId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'DELETE' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when deleting group', + ) + throw new Error(newErrorMessage) + } + + const remainingGroups = groups.filter( + (group: GroupResponse) => group._id !== selectedGroupId, + ) + setGroups(remainingGroups) + setSelectedGroupId(remainingGroups[0]?._id ?? '') + } + + async function processGroupMembershipRowUpdate(newRow: GridRowModel) { + if (!selectedGroupId) { + return newRow + } + const userId = String(newRow.userId) + const isMember = Boolean(newRow.isMember) + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/memberships/${selectedGroupId}/${userId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ isMember }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating group membership', + ) + throw new Error(newErrorMessage) + } + + setGroupMembershipByUserId((prev: Partial>) => ({ + ...prev, + [userId]: isMember, + })) + if (selectedUserId && selectedUserId === userId) { + await fetchEffectiveAssemblyPermissionsForUser(selectedUserId) + } + + return { + ...newRow, + isMember, + } + } + + async function processUserGroupMembershipRowUpdate(newRow: GridRowModel) { + if (!selectedUserId) { + return newRow + } + const groupId = String(newRow.groupId) + const isMember = Boolean(newRow.isMember) + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/memberships/${groupId}/${selectedUserId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ isMember }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating user group membership', + ) + throw new Error(newErrorMessage) + } + + setGroupMembershipByGroupId((prev: Partial>) => ({ + ...prev, + [groupId]: isMember, + })) + await fetchEffectiveAssemblyPermissionsForUser(selectedUserId) + + return { + ...newRow, + isMember, + } + } + + async function processGroupAssemblyPermissionRowUpdate(newRow: GridRowModel) { + if (!selectedGroupId) { + return newRow + } + const { canViewAnnotations, canEditAnnotations } = + normalizeAssemblyPermissionUpdate({ + canViewAnnotations: Boolean(newRow.canViewAnnotations), + canEditAnnotations: Boolean(newRow.canEditAnnotations), + }) + + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/permissions/${selectedGroupId}/${newRow.assemblyId as string}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + canViewAnnotations, + canEditAnnotations, + }), + }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating group assembly permission', + ) + throw new Error(newErrorMessage) + } + + const savedPermission = + (await response.json()) as GroupAssemblyPermissionResponse + setGroupPermissionsByAssemblyId( + (prev: Partial>) => ({ + ...prev, + [savedPermission.assemblyId]: savedPermission, + }), + ) + // Refresh the selected user's effective view only when this group currently + // contributes to that user's inherited permissions. + if (selectedUserId && Boolean(groupMembershipByGroupId[selectedGroupId])) { + await fetchEffectiveAssemblyPermissionsForUser(selectedUserId) + } + + return { + ...newRow, + canViewAnnotations, + canEditAnnotations, + } + } + + async function toggleAssemblyPermission( + row: AssemblyPermissionRow, + field: 'canViewAnnotations' | 'canEditAnnotations', + checked: boolean, + ) { + await processAssemblyPermissionRowUpdate({ + ...row, + [field]: checked, + }) + } + + async function toggleGroupMembership( + row: GroupMembershipRow, + checked: boolean, + ) { + await processGroupMembershipRowUpdate({ + ...row, + isMember: checked, + }) + } + + async function toggleGroupAssemblyPermission( + row: AssemblyPermissionRow, + field: 'canViewAnnotations' | 'canEditAnnotations', + checked: boolean, + ) { + await processGroupAssemblyPermissionRowUpdate({ + ...row, + [field]: checked, + }) + } + + function buildTogglePermissionColumn( + field: 'canViewAnnotations' | 'canEditAnnotations', + headerName: string, + onToggle: (row: AssemblyPermissionRow, checked: boolean) => Promise, + disabled: (params: GridRenderCellParams) => boolean, + ): GridColDef { + return { + field, + headerName, + width: 210, + sortable: false, + editable: false, + renderCell: (params: GridRenderCellParams) => ( + ) => { + event.stopPropagation() + }} + onChange={( + _event: React.ChangeEvent, + checked: boolean, + ) => { + onToggle(params.row, checked).catch((error) => { + setErrorMessage(String(error)) + }) + }} + /> + ), + } + } + + function buildEffectivePermissionColumn( + field: 'canViewAnnotations' | 'canEditAnnotations', + headerName: string, + ): GridColDef { + return { + field, + headerName, + width: 170, + sortable: false, + editable: false, + renderCell: ( + params: GridRenderCellParams, + ) => { + const enabled = Boolean(params.row[field]) + return ( + : } + label={enabled ? 'Allowed' : 'No'} + color={enabled ? 'success' : 'default'} + variant={enabled ? 'filled' : 'outlined'} + sx={{ + minWidth: 98, + fontWeight: 700, + '& .MuiChip-icon': { + fontSize: '1rem', + }, + }} + /> + ) + }, + } + } + + const selectedManagedUser = users.find( + (user: UserResponse) => user._id === selectedUserId, + ) + const selectedManagedGroup = groups.find( + (group: GroupResponse) => group._id === selectedGroupId, + ) + + const assembliesByLookup = useMemo(() => { + const next = new Map() + for (const assembly of assemblies) { + next.set(assembly._id, assembly) + next.set(assembly.name, assembly) + next.set(assembly.name.toLowerCase(), assembly) + if (assembly.displayName) { + next.set(assembly.displayName, assembly) + next.set(assembly.displayName.toLowerCase(), assembly) + } + } + return next + }, [assemblies]) + + const defaultScientificName = useMemo(() => { + const counts = new Map() + for (const assembly of assemblies) { + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + if (scientificName) { + counts.set(scientificName, (counts.get(scientificName) ?? 0) + 1) + } + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]) + return ranked[0]?.[0] + }, [assemblies]) + + function findAssemblyForGroupToken(token?: string) { + if (!token) { + return + } + const cleanedToken = token.trim() + if (!cleanedToken) { + return + } + return ( + assembliesByLookup.get(cleanedToken) ?? + assembliesByLookup.get(cleanedToken.toLowerCase()) + ) + } + + function getGroupGenusSpecies(group: GroupResponse) { + const assemblyPrefix = 'assembly:' + if (group.name.startsWith(assemblyPrefix)) { + const assemblyToken = group.name.slice(assemblyPrefix.length).trim() + const assembly = findAssemblyForGroupToken(assemblyToken) + if (assembly) { + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + if (scientificName.length > 0) { + return scientificName + } + } + return defaultScientificName || 'Unknown' + } + + const directAssemblyMatch = findAssemblyForGroupToken(group.name) + if (directAssemblyMatch) { + const scientificName = + typeof directAssemblyMatch.scientificName === 'string' + ? directAssemblyMatch.scientificName.trim() + : '' + if (scientificName.length > 0) { + return scientificName + } + return defaultScientificName || 'Unknown' + } + + const descriptionMatch = group.description?.match(/assembly\s+(.+)$/i) + const assemblyTokenFromDescription = descriptionMatch?.[1] + ?.replace(/[.,;:]$/, '') + .trim() + if (assemblyTokenFromDescription) { + const assembly = findAssemblyForGroupToken(assemblyTokenFromDescription) + if (assembly) { + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + if (scientificName.length > 0) { + return scientificName + } + } + return defaultScientificName || 'Unknown' + } + + return defaultScientificName || 'Unknown' + } + + const filteredGroups = useMemo(() => { + const query = groupFilterText.trim().toLowerCase() + if (!query) { + return groups + } + + return groups.filter((group: GroupResponse) => { + const haystack = `${group.name} ${group.description ?? ''}`.toLowerCase() + return haystack.includes(query) + }) + }, [groupFilterText, groups]) + + const selectableGroups = useMemo(() => { + if (!selectedManagedGroup) { + return filteredGroups + } + if ( + filteredGroups.some( + (group: GroupResponse) => group._id === selectedManagedGroup._id, + ) + ) { + return filteredGroups + } + return [selectedManagedGroup, ...filteredGroups] + }, [filteredGroups, selectedManagedGroup]) + + const assemblyPermissionRows: AssemblyPermissionRow[] = + buildAssemblyPermissionRows(assemblies, assemblyPermissionsByAssemblyId) + + const effectiveAssemblyPermissionRows: EffectiveAssemblyPermissionRow[] = + assemblies + .map((assembly: AssemblyResponse) => { + const permission = + effectiveAssemblyPermissionsByAssemblyId[assembly._id] + const source: EffectiveAssemblyPermissionRow['source'] = + permission?.source ?? 'none' + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + return { + id: assembly._id, + assemblyId: assembly._id, + assemblyName: assembly.displayName ?? assembly.name, + genusSpecies: scientificName || 'Unknown', + canViewAnnotations: permission?.canViewAnnotations ?? false, + canEditAnnotations: permission?.canEditAnnotations ?? false, + source, + } + }) + .sort( + ( + a: EffectiveAssemblyPermissionRow, + b: EffectiveAssemblyPermissionRow, + ) => a.assemblyName.localeCompare(b.assemblyName), + ) + + const groupMembershipRows: GroupMembershipRow[] = users + .map((user: UserResponse) => ({ + id: user._id, + userId: user._id, + username: user.username, + email: user.email, + isMember: Boolean(groupMembershipByUserId[user._id]), + })) + .sort((a: GroupMembershipRow, b: GroupMembershipRow) => + a.username.localeCompare(b.username), + ) + + const userGroupMembershipRows: UserGroupMembershipRow[] = groups + .map((group: GroupResponse) => ({ + id: group._id, + groupId: group._id, + groupName: group.name, + genusSpecies: getGroupGenusSpecies(group), + description: group.description ?? '', + isMember: Boolean(groupMembershipByGroupId[group._id]), + })) + .sort((a: UserGroupMembershipRow, b: UserGroupMembershipRow) => + a.groupName.localeCompare(b.groupName), + ) + + const groupAssemblyPermissionRows: AssemblyPermissionRow[] = + buildAssemblyPermissionRows(assemblies, groupPermissionsByAssemblyId).sort( + (a: AssemblyPermissionRow, b: AssemblyPermissionRow) => + a.assemblyName.localeCompare(b.assemblyName), + ) + + const enabledGroupMembershipRows = groupMembershipRows.filter( + (row: GroupMembershipRow) => row.isMember, + ) + + const enabledGroupAssemblyPermissionRows = groupAssemblyPermissionRows.filter( + (row: AssemblyPermissionRow) => + row.canViewAnnotations || row.canEditAnnotations, + ) + + const groupSummary = selectedManagedGroup + ? `${enabledGroupMembershipRows.length} members, ${enabledGroupAssemblyPermissionRows.length} assemblies with access` + : 'Create or select a group to manage inherited access.' + + const assemblyPermissionColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, + { + field: 'assemblyName', + headerName: 'Assembly', + width: 280, + editable: false, + }, + buildTogglePermissionColumn( + 'canViewAnnotations', + 'Can view annotations', + async (row, checked) => { + await toggleAssemblyPermission(row, 'canViewAnnotations', checked) + }, + () => !selectedUserId, + ), + buildTogglePermissionColumn( + 'canEditAnnotations', + 'Can edit annotations', + async (row, checked) => { + await toggleAssemblyPermission(row, 'canEditAnnotations', checked) + }, + () => !selectedUserId, + ), + ] + + const effectiveAssemblyPermissionColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, + { + field: 'assemblyName', + headerName: 'Assembly', + width: 280, + editable: false, + }, + buildEffectivePermissionColumn('canViewAnnotations', 'Effective view'), + buildEffectivePermissionColumn('canEditAnnotations', 'Effective edit'), + { + field: 'source', + headerName: 'Source', + width: 160, + editable: false, + type: 'singleSelect', + valueOptions: ['none', 'direct', 'group', 'mixed'], + }, + ] + + const groupMembershipColumns: GridColDef[] = [ + { + field: 'username', + headerName: 'User', + width: 180, + editable: false, + filterable: true, + }, + { + field: 'email', + headerName: 'Email', + width: 240, + editable: false, + filterable: true, + }, + { + field: 'isMember', + headerName: 'Member', + width: 140, + sortable: false, + editable: false, + filterable: true, + renderCell: (params: GridRenderCellParams) => ( + ) => { + event.stopPropagation() + }} + onChange={( + _event: React.ChangeEvent, + checked: boolean, + ) => { + toggleGroupMembership(params.row, checked).catch((error) => { + setErrorMessage(String(error)) + }) + }} + /> + ), + }, + ] + + const groupMembershipStateColumns: GridColDef[] = [ + { + field: 'username', + headerName: 'User', + width: 180, + editable: false, + filterable: true, + }, + { + field: 'email', + headerName: 'Email', + width: 240, + editable: false, + filterable: true, + }, + ] + + const userGroupMembershipColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, + { + field: 'groupName', + headerName: 'Group', + width: 260, + editable: false, + filterable: true, + }, + { + field: 'description', + headerName: 'Description', + width: 320, + editable: false, + filterable: true, + }, + { + field: 'isMember', + headerName: 'Member', + width: 140, + sortable: false, + editable: false, + filterable: true, + renderCell: (params: GridRenderCellParams) => ( + ) => { + event.stopPropagation() + }} + onChange={( + _event: React.ChangeEvent, + checked: boolean, + ) => { + processUserGroupMembershipRowUpdate({ + ...params.row, + isMember: checked, + }).catch((error) => { + setErrorMessage(String(error)) + }) + }} + /> + ), + }, + ] + + const groupDirectoryColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, + { + field: 'name', + headerName: 'Group', + width: 260, + editable: false, + filterable: true, + }, + { + field: 'description', + headerName: 'Description', + width: 320, + editable: false, + filterable: true, + }, + ] + + const groupDirectoryRows = groups.map((group: GroupResponse) => ({ + id: group._id, + _id: group._id, + name: group.name, + genusSpecies: getGroupGenusSpecies(group), + description: group.description ?? '', + })) + + const groupManagementControls = ( + <> + + ) => { + setNewGroupName(event.target.value) + }} + /> + ) => { + setNewGroupDescription(event.target.value) + }} + /> + + + + + ) => { + setGroupFilterText(event.target.value) + }} + sx={{ minWidth: 320, marginBottom: 1 }} + /> + + + Managed group + + + +
+ { + setSelectedGroupId(String(params.id)) + }} + /> +
+ {selectedManagedGroup ? ( + + {`Managing ${selectedManagedGroup.name}: ${groupSummary}`} + + ) : ( + + {groupSummary} + + )} + {groupFilterText ? ( + + {`${filteredGroups.length} groups match "${groupFilterText.trim()}".`} + + ) : null} + + ) + + const groupAssemblyPermissionColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, + { + field: 'assemblyName', + headerName: 'Assembly', + width: 280, + editable: false, + filterable: true, + }, + buildTogglePermissionColumn( + 'canViewAnnotations', + 'Can view annotations', + async (row, checked) => { + await toggleGroupAssemblyPermission(row, 'canViewAnnotations', checked) + }, + () => !selectedGroupId, + ), + buildTogglePermissionColumn( + 'canEditAnnotations', + 'Can edit annotations', + async (row, checked) => { + await toggleGroupAssemblyPermission(row, 'canEditAnnotations', checked) + }, + () => !selectedGroupId, + ), + ] + + const groupAssemblyPermissionStateColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, + { + field: 'assemblyName', + headerName: 'Assembly', + width: 280, + editable: false, + filterable: true, + }, + buildEffectivePermissionColumn('canViewAnnotations', 'View enabled'), + buildEffectivePermissionColumn('canEditAnnotations', 'Edit enabled'), + ] + return ( - {internetAccounts.map((option) => ( - - {option.name} - - ))} + {apolloInternetAccounts.map( + (option: ApolloInternetAccountModel) => ( + + { + (option as ApolloInternetAccountModel & { name: string }) + .name + } + + ), + )} ) : null}
- row._id} - slots={{ toolbar: GridToolbar }} - getRowHeight={() => 'auto'} - isCellEditable={(params: GridCellParams) => - !isCurrentUser(params.id) - } - processRowUpdate={processRowUpdate} - onProcessRowUpdateError={(error) => { - setErrorMessage(String(error)) + { + setManagementSection(value) }} - /> + sx={manageUsersTabsSx} + > + + + + + {managementSection === 'users' ? ( + <> + + User roles + + + row._id} + slots={{ toolbar: GridToolbar }} + slotProps={{ toolbar: { showQuickFilter: true } }} + sx={apolloDataGridSx} + getRowHeight={() => 'auto'} + isCellEditable={(params: GridCellParams) => + !isCurrentUser(params.id) + } + processRowUpdate={processRowUpdate} + onProcessRowUpdateError={(error: unknown) => { + setErrorMessage(String(error)) + }} + /> + + + + Permission management + + { + setPermissionView(value) + }} + sx={manageUsersTabsSx} + > + + + + + + + + Managed user + + + + + {permissionView === 'effective' ? ( + <> + {selectedManagedUser ? ( + + {`Effective access for ${selectedManagedUser.username}`} + + ) : null} +
+ +
+ + ) : null} + + {permissionView === 'assembly' ? ( + <> + {selectedManagedUser ? ( + + {`Editing direct user access for ${selectedManagedUser.username}`} + + ) : null} +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + /> +
+ + ) : null} + + {permissionView === 'userGroupMemberships' ? ( + <> + {selectedManagedUser ? ( + + {`Toggle group membership for ${selectedManagedUser.username}. Changes are saved row by row.`} + + ) : null} +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + isCellEditable={(params: GridCellParams) => + Boolean(selectedUserId) && params.field === 'isMember' + } + /> +
+ + ) : null} +
+ + ) : null} + + {managementSection === 'groups' ? ( + <> + + Group management + + { + setGroupManagementView(value) + }} + sx={manageUsersTabsSx} + > + + + + + {groupManagementControls} + + {groupManagementView === 'memberships' ? ( + <> + + Group memberships + + + { + setGroupMembershipView(value) + }} + sx={manageUsersTabsSx} + > + + + + + {groupMembershipView === 'current' ? ( + <> + + {selectedManagedGroup + ? `${enabledGroupMembershipRows.length} users are currently members of this group.` + : 'Select a group to review its current memberships.'} + + + + Active group memberships + + {enabledGroupMembershipRows.length > 0 ? ( +
+ +
+ ) : ( + + No users are currently members of this group. + + )} + + ) : null} + + {groupMembershipView === 'edit' ? ( + <> + + Toggle group membership here. Changes are saved row by + row. + + + + Group memberships + +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + isCellEditable={(params: GridCellParams) => + Boolean(selectedGroupId) && + params.field === 'isMember' + } + /> +
+ + ) : null} + + ) : null} + + {groupManagementView === 'permissions' ? ( + <> + + Group permissions + + + { + setGroupPermissionView(value) + }} + sx={manageUsersTabsSx} + > + + + + + {groupPermissionView === 'current' ? ( + <> + + {selectedManagedGroup + ? `${enabledGroupAssemblyPermissionRows.length} assemblies currently have inherited access in this group.` + : 'Select a group to review its current assembly permissions.'} + + + + Enabled assembly permissions + + {enabledGroupAssemblyPermissionRows.length > 0 ? ( +
+ +
+ ) : ( + + No assembly permissions are currently enabled for this + group. + + )} + + ) : null} + + {groupPermissionView === 'edit' ? ( + <> + + Toggle group assembly access here. Changes are saved row + by row. + + + + Group assembly permissions + +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + isCellEditable={(params: GridCellParams) => + Boolean(selectedGroupId) && + (params.field === 'canViewAnnotations' || + params.field === 'canEditAnnotations') + } + /> +
+ + ) : null} + + ) : null} + + ) : null}
@@ -237,6 +2048,89 @@ export function ManageUsers({ {errorMessage} ) : null} + {localUserDialogOpen ? ( + { + setLocalUserErrorMessage('') + setLocalUserDialogOpen(false) + }} + maxWidth="sm" + > + + ) => { + setNewLocalUsername(event.target.value) + }} + /> + ) => { + setNewLocalEmail(event.target.value) + }} + /> + ) => { + setNewLocalPassword(event.target.value) + }} + /> + + Role + + + + + + + + {localUserErrorMessage ? ( + + + {localUserErrorMessage} + + + ) : null} + + ) : null}
) } diff --git a/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx new file mode 100644 index 000000000..981a8d59a --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx @@ -0,0 +1,530 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import { getDecodedToken } from '@apollo-annotation/shared' +import { applySnapshot, isAlive } from '@jbrowse/mobx-state-tree' +import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view' +import FilterListIcon from '@mui/icons-material/FilterList' +import { + Box, + Button, + Chip, + DialogActions, + DialogContent, + DialogContentText, + FormControlLabel, + Switch, + Typography, +} from '@mui/material' +import { + DataGrid, + type GridColDef, + type GridRenderCellParams, + GridToolbar, +} from '@mui/x-data-grid' +import React, { type ChangeEvent, useEffect, useMemo, useState } from 'react' + +import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' +import type { ApolloSessionModel } from '../session' +import { type ApolloRootModel, isApolloInternetAccount } from '../types' +import { createFetchErrorMessage } from '../util' + +import { Dialog } from './Dialog' +import { apolloDataGridSx } from './dataGridStyles' + +interface AssemblyResponse { + _id: string + name: string + displayName?: string + scientificName?: string +} + +interface AssemblyPermissionResponse { + _id?: string + userId: string + assemblyId: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +interface PermissionRow { + id: string + assemblyId: string + assemblyRefName: string + assemblyName: string + genusSpecies: string + access: 'Edit' | 'View' +} + +interface MyAssemblyPermissionsProps { + rootModel: ApolloRootModel + handleClose: () => void +} + +interface SessionWithLinearGenomeView { + views: unknown[] + addView?: (viewType: string, ...args: unknown[]) => unknown + id?: string + name?: string +} + +interface ApolloRegion { + assemblyName?: string + refName: string + start: number + end: number +} + +function wait(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function safeRetrieveToken(account: ApolloInternetAccountModel) { + try { + if (!isAlive(account)) { + return + } + return account.retrieveToken() ?? undefined + } catch { + return + } +} + +function isGuestToken(token: string) { + try { + const { username, email } = getDecodedToken(token) + return ( + username.toLowerCase() === 'guest' || email.toLowerCase() === 'guest_user' + ) + } catch { + return false + } +} + +export function MyAssemblyPermissions({ + handleClose, + rootModel, +}: MyAssemblyPermissionsProps) { + const { internetAccounts } = rootModel + const apolloInternetAccounts: ApolloInternetAccountModel[] = internetAccounts + .filter((ia) => isAlive(ia)) + .filter((ia) => isApolloInternetAccount(ia)) + .filter((ia) => Boolean(safeRetrieveToken(ia))) + const preferredInternetAccount = + apolloInternetAccounts.find((ia) => { + const token = safeRetrieveToken(ia) + return token ? !isGuestToken(token) : false + }) ?? apolloInternetAccounts[0] + const [selectedInternetAccount] = useState(preferredInternetAccount) + const [rows, setRows] = useState([]) + const [errorMessage, setErrorMessage] = useState('') + const [loading, setLoading] = useState(true) + const [editOnly, setEditOnly] = useState(false) + + useEffect(() => { + async function loadPermissions() { + if (!selectedInternetAccount) { + setRows([]) + setLoading(false) + setErrorMessage('Sign in to Apollo, then reopen My workspace.') + return + } + setLoading(true) + setErrorMessage('') + const { baseURL } = selectedInternetAccount + const assembliesUri = new URL('assemblies', baseURL).href + const permissionsUri = new URL('assemblyPermissions/mine', baseURL).href + const apolloFetchAssemblies = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: assembliesUri, + }) + const apolloFetchPermissions = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: permissionsUri, + }) + const [assembliesResponse, permissionsResponse] = await Promise.all([ + apolloFetchAssemblies(assembliesUri, { method: 'GET' }), + apolloFetchPermissions(permissionsUri, { method: 'GET' }), + ]) + + if (!assembliesResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + assembliesResponse, + 'Error when loading assemblies', + ) + throw new Error(newErrorMessage) + } + if (!permissionsResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + permissionsResponse, + 'Error when loading your annotation permissions', + ) + throw new Error(newErrorMessage) + } + + const assemblies = (await assembliesResponse.json()) as AssemblyResponse[] + const permissions = + (await permissionsResponse.json()) as AssemblyPermissionResponse[] + const assemblyById = new Map( + assemblies.map((assembly) => [assembly._id, assembly]), + ) + + const nextRows: PermissionRow[] = permissions + .filter((permission) => permission.canViewAnnotations) + .map((permission) => { + const assembly = assemblyById.get(permission.assemblyId) + const assemblyName = + assembly?.displayName ?? assembly?.name ?? permission.assemblyId + const scientificName = + typeof assembly?.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + return { + id: + permission._id ?? `${permission.userId}-${permission.assemblyId}`, + assemblyId: permission.assemblyId, + assemblyRefName: assembly?.name ?? permission.assemblyId, + assemblyName, + genusSpecies: scientificName || 'Unknown', + access: permission.canEditAnnotations + ? ('Edit' as const) + : ('View' as const), + } + }) + .sort((a, b) => a.assemblyName.localeCompare(b.assemblyName)) + + setRows(nextRows) + setLoading(false) + } + + loadPermissions().catch((error: unknown) => { + setErrorMessage(String(error)) + setRows([]) + setLoading(false) + }) + }, [selectedInternetAccount]) + + const filteredRows = useMemo( + () => rows.filter((row) => !editOnly || row.access === 'Edit'), + [editOnly, rows], + ) + + async function loadAssembly( + assemblyName: string, + options?: { openInNewView?: boolean }, + ) { + setErrorMessage('') + if ( + !isAlive(rootModel) || + !rootModel.session || + !isAlive(rootModel.session) + ) { + setErrorMessage( + 'The current session is no longer available. Reopen My workspace.', + ) + return + } + const sessionModel = + rootModel.session as unknown as SessionWithLinearGenomeView & { + apolloSetSelectedFeature?: (feature?: unknown) => void + } + if (!sessionModel) { + setErrorMessage( + 'The current session is no longer available. Reopen My workspace.', + ) + return + } + const openInNewView = options?.openInNewView ?? false + let linearGenomeView: LinearGenomeViewModel | undefined + + if (!openInNewView) { + // Replace semantics: reset to an empty session and create one fresh view. + const baseSnapshot = { + id: sessionModel.id, + name: sessionModel.name, + } + applySnapshot(rootModel.session as unknown as object, baseSnapshot) + + const freshSession = + rootModel.session as unknown as SessionWithLinearGenomeView & { + apolloSetSelectedFeature?: (feature?: unknown) => void + } + const createdView = freshSession.addView?.('LinearGenomeView') + linearGenomeView = + (createdView as LinearGenomeViewModel | undefined) ?? + (freshSession.views.find( + (view) => + (view as { type?: string } | undefined)?.type === + 'LinearGenomeView', + ) as LinearGenomeViewModel | undefined) + } else if (openInNewView && sessionModel.addView) { + const existingViews = new Set(sessionModel.views) + const createdView = sessionModel.addView('LinearGenomeView') + linearGenomeView = + (createdView as LinearGenomeViewModel | undefined) ?? + (sessionModel.views.find( + (view) => + !existingViews.has(view) && + (view as { type?: string } | undefined)?.type === + 'LinearGenomeView', + ) as LinearGenomeViewModel | undefined) + } else { + linearGenomeView = sessionModel.views.find( + (view) => + (view as { type?: string } | undefined)?.type === 'LinearGenomeView', + ) as LinearGenomeViewModel | undefined + } + + if (!linearGenomeView && sessionModel.addView && !openInNewView) { + // Create a linear genome view on-demand so Load works from the start screen. + const createdView = sessionModel.addView('LinearGenomeView') + linearGenomeView = + (createdView as LinearGenomeViewModel | undefined) ?? + (sessionModel.views.find( + (view) => + (view as { type?: string } | undefined)?.type === + 'LinearGenomeView', + ) as LinearGenomeViewModel | undefined) + } + + if (!linearGenomeView) { + setErrorMessage('Could not open a Linear Genome View for this session.') + return + } + + sessionModel.apolloSetSelectedFeature?.() + + const getDisplayedAssemblyName = () => { + const regions = ( + linearGenomeView as unknown as { + displayedRegions?: { assemblyName?: string }[] + } + ).displayedRegions + return regions?.[0]?.assemblyName + } + + const openByExplicitRegion = async () => { + const apolloSession = rootModel.session as unknown as ApolloSessionModel + const backendDriver = + apolloSession.apolloDataStore.getBackendDriver(assemblyName) + if (!backendDriver) { + return false + } + const regions = (await backendDriver.getRegions( + assemblyName, + )) as ApolloRegion[] + const [firstRegion] = regions + if (!firstRegion) { + return false + } + await linearGenomeView.navToLocation({ + assemblyName, + refName: firstRegion.refName, + start: firstRegion.start, + end: firstRegion.end, + }) + return true + } + + let lastError: unknown + for (let attempt = 0; attempt < 8; attempt++) { + try { + linearGenomeView.showAllRegionsInAssembly(assemblyName) + if (getDisplayedAssemblyName() !== assemblyName) { + const opened = await openByExplicitRegion() + if (!opened) { + throw new Error( + `Could not resolve selected assembly regions for ${assemblyName}`, + ) + } + } + handleClose() + return + } catch (error) { + lastError = error + await wait(150) + } + } + + setErrorMessage(`Could not load assembly: ${String(lastError)}`) + } + + const gridColumns: GridColDef[] = [ + { + field: 'loadAssembly', + headerName: 'Open', + sortable: false, + filterable: false, + minWidth: 220, + maxWidth: 240, + renderCell: ({ row }: GridRenderCellParams) => ( + + + + + ), + }, + { + field: 'genusSpecies', + headerName: 'Organism', + flex: 1, + minWidth: 220, + }, + { + field: 'assemblyName', + headerName: 'Assembly', + flex: 1, + minWidth: 220, + }, + { + field: 'access', + headerName: 'Annotation access', + minWidth: 180, + renderCell: ({ row }: GridRenderCellParams) => ( + : undefined} + /> + ), + }, + ] + + const editCount = rows.filter((row) => row.access === 'Edit').length + + const handleEditOnlyChange = ( + _event: ChangeEvent, + checked: boolean, + ) => { + setEditOnly(checked) + } + + if (!selectedInternetAccount) { + return ( + + + + No authenticated Apollo session was found. Sign in from the Apollo + menu and reopen My workspace. + + + + + + + ) + } + + return ( + + + + + Assemblies in your Apollo workspace where you can view or edit + annotations. + + + } + label="Edit-only" + /> + + + {filteredRows.length} shown ({editCount} with Edit access) + +
+ +
+ {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ + + + +
+ ) +} diff --git a/packages/jbrowse-plugin-apollo/src/components/ViewChangeLog.tsx b/packages/jbrowse-plugin-apollo/src/components/ViewChangeLog.tsx index 1867ebf6a..6d047a595 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ViewChangeLog.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ViewChangeLog.tsx @@ -26,6 +26,7 @@ import type { GetChangesOpts } from '../BackendDrivers/BackendDriver' import type { ApolloSessionModel } from '../session' import { Dialog } from './Dialog' +import { apolloDataGridSx } from './dataGridStyles' interface ViewChangeLogProps { session: ApolloSessionModel @@ -216,6 +217,7 @@ export function ViewChangeLog({ columns={gridColumns} getRowId={(row) => row.sequence} showToolbar + sx={apolloDataGridSx} pageSizeOptions={[5, 15, 25, 50, 100]} initialState={{ columns: { columnVisibilityModel: { sequence: false } }, diff --git a/packages/jbrowse-plugin-apollo/src/components/ViewCheckResults.tsx b/packages/jbrowse-plugin-apollo/src/components/ViewCheckResults.tsx index 21d3124b7..0eda7d237 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ViewCheckResults.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ViewCheckResults.tsx @@ -14,6 +14,7 @@ import React, { useEffect, useState } from 'react' import type { ApolloSessionModel } from '../session' import { Dialog } from './Dialog' +import { apolloDataGridSx } from './dataGridStyles' interface ViewCheckResultsProps { session: ApolloSessionModel @@ -75,6 +76,7 @@ export function ViewCheckResults({ columns={gridColumns} getRowId={(row) => row._id} showToolbar + sx={apolloDataGridSx} initialState={{ sorting: { sortModel: [{ field: 'name', sort: 'asc' }] }, columns: { columnVisibilityModel: { name: true } }, diff --git a/packages/jbrowse-plugin-apollo/src/components/dataGridStyles.ts b/packages/jbrowse-plugin-apollo/src/components/dataGridStyles.ts new file mode 100644 index 000000000..8c13621a5 --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/dataGridStyles.ts @@ -0,0 +1,42 @@ +export const apolloDataGridSx = { + border: '1px solid', + borderColor: 'divider', + borderRadius: 3, + backgroundColor: 'background.paper', + overflow: 'hidden', + '& .MuiDataGrid-toolbarContainer': { + padding: '10px 12px', + borderBottom: '1px solid', + borderColor: 'divider', + backgroundColor: 'grey.50', + }, + '& .MuiDataGrid-columnHeaders': { + backgroundColor: 'grey.100', + borderBottom: '1px solid', + borderColor: 'divider', + }, + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 700, + color: 'text.primary', + letterSpacing: '0.01em', + }, + '& .MuiDataGrid-columnSeparator': { + color: 'divider', + }, + '& .MuiDataGrid-cell': { + alignItems: 'center', + borderColor: 'divider', + borderBottomColor: 'divider', + }, + '& .MuiDataGrid-row:nth-of-type(even)': { + backgroundColor: 'grey.50', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'action.hover', + }, + '& .MuiDataGrid-footerContainer': { + borderTop: '1px solid', + borderColor: 'divider', + backgroundColor: 'grey.50', + }, +} as const diff --git a/packages/jbrowse-plugin-apollo/src/components/index.ts b/packages/jbrowse-plugin-apollo/src/components/index.ts index bc7c05000..17a521369 100644 --- a/packages/jbrowse-plugin-apollo/src/components/index.ts +++ b/packages/jbrowse-plugin-apollo/src/components/index.ts @@ -5,11 +5,13 @@ export * from './AddFeature' export * from './ColorFeature' export * from './CopyFeature' export * from './DeleteAssembly' +export * from './EditAssemblies' export * from './DeleteFeature' export * from './DownloadGFF3' export * from './ImportFeatures' export * from './LogOut' export * from './ManageChecks' +export * from './MyAssemblyPermissions' export * from './ManageUsers' export * from './MergeExons' export * from './MergeTranscripts' diff --git a/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.test.ts b/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.test.ts new file mode 100644 index 000000000..8a2d69556 --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from '@jest/globals' + +import { + type AssemblyPermissionResponse, + type AssemblyResponse, + buildAssemblyPermissionRows, + indexPermissionsByAssemblyId, + normalizeAssemblyPermissionUpdate, +} from './manageUsersAssemblyPermissions' + +describe('manageUsersAssemblyPermissions', () => { + it('indexPermissionsByAssemblyId creates assembly-keyed map', () => { + const permissions: AssemblyPermissionResponse[] = [ + { + _id: 'p1', + userId: 'u1', + assemblyId: 'a1', + canViewAnnotations: true, + canEditAnnotations: false, + }, + { + _id: 'p2', + userId: 'u1', + assemblyId: 'a2', + canViewAnnotations: true, + canEditAnnotations: true, + }, + ] + + const result = indexPermissionsByAssemblyId(permissions) + + expect(result.a1?._id).toBe('p1') + expect(result.a2?._id).toBe('p2') + }) + + it('normalizeAssemblyPermissionUpdate enforces edit implies view', () => { + const result = normalizeAssemblyPermissionUpdate({ + canViewAnnotations: false, + canEditAnnotations: true, + }) + + expect(result).toEqual({ + canViewAnnotations: true, + canEditAnnotations: true, + }) + }) + + it('buildAssemblyPermissionRows defaults missing permissions to false', () => { + const assemblies: AssemblyResponse[] = [ + { _id: 'a1', name: 'asm1', displayName: 'Assembly 1' }, + { _id: 'a2', name: 'asm2' }, + ] + const indexed = indexPermissionsByAssemblyId([ + { + _id: 'p1', + userId: 'u1', + assemblyId: 'a1', + canViewAnnotations: true, + canEditAnnotations: false, + }, + ]) + + const rows = buildAssemblyPermissionRows(assemblies, indexed) + + expect(rows).toEqual([ + { + id: 'a1', + assemblyId: 'a1', + assemblyName: 'Assembly 1', + canViewAnnotations: true, + canEditAnnotations: false, + }, + { + id: 'a2', + assemblyId: 'a2', + assemblyName: 'asm2', + canViewAnnotations: false, + canEditAnnotations: false, + }, + ]) + }) +}) diff --git a/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts b/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts new file mode 100644 index 000000000..a6675a8d0 --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts @@ -0,0 +1,75 @@ +export interface AssemblyResponse { + _id: string + name: string + displayName?: string + scientificName?: string +} + +export interface AssemblyPermissionResponse { + _id: string + userId: string + assemblyId: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +export interface AssemblyPermissionLike { + assemblyId: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +export interface AssemblyPermissionRow { + id: string + assemblyId: string + assemblyName: string + genusSpecies: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +export interface AssemblyPermissionUpdateInput { + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +export function indexPermissionsByAssemblyId( + permissions: AssemblyPermissionResponse[], +): Partial> { + const byAssemblyId: Partial> = {} + for (const permission of permissions) { + byAssemblyId[permission.assemblyId] = permission + } + return byAssemblyId +} + +export function normalizeAssemblyPermissionUpdate( + input: AssemblyPermissionUpdateInput, +): AssemblyPermissionUpdateInput { + const canEditAnnotations = Boolean(input.canEditAnnotations) + return { + canEditAnnotations, + canViewAnnotations: Boolean(input.canViewAnnotations) || canEditAnnotations, + } +} + +export function buildAssemblyPermissionRows( + assemblies: AssemblyResponse[], + permissionsByAssemblyId: Partial>, +): AssemblyPermissionRow[] { + return assemblies.map((assembly) => { + const permission = permissionsByAssemblyId[assembly._id] + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + return { + id: assembly._id, + assemblyId: assembly._id, + assemblyName: assembly.displayName ?? assembly.name, + genusSpecies: scientificName || 'Unknown', + canViewAnnotations: permission?.canViewAnnotations ?? false, + canEditAnnotations: permission?.canEditAnnotations ?? false, + } + }) +} diff --git a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts index 9f31d7f02..186b45f7d 100644 --- a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts +++ b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts @@ -1,15 +1,138 @@ +import { getDecodedToken } from '@apollo-annotation/shared' import type { AbstractMenuManager, AbstractSessionModel, } from '@jbrowse/core/util' +import { isAlive } from '@jbrowse/mobx-state-tree' +import AccountCircleIcon from '@mui/icons-material/AccountCircle' +import FactCheckIcon from '@mui/icons-material/FactCheck' import LogoutIcon from '@mui/icons-material/Logout' import RedoIcon from '@mui/icons-material/Redo' import UndoIcon from '@mui/icons-material/Undo' +import React from 'react' -import { LogOut } from '../components' +import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' +import { LogOut, MyAssemblyPermissions } from '../components' import type { ApolloSessionModel } from '../session' import { type ApolloRootModel, isApolloInternetAccount } from '../types' +function getApolloInternetAccounts(rootModel: ApolloRootModel) { + const { internetAccounts } = rootModel + return internetAccounts.filter((account) => { + try { + if (!isAlive(account)) { + return false + } + return isApolloInternetAccount(account) + } catch { + return false + } + }) as ApolloInternetAccountModel[] +} + +function getStoredToken(account?: ApolloInternetAccountModel) { + if (!account) { + return + } + try { + if (!isAlive(account)) { + return + } + const accountWithTokenKey = account as ApolloInternetAccountModel & { + tokenKey?: string + } + if (!accountWithTokenKey.tokenKey) { + return + } + const token = globalThis.sessionStorage.getItem( + accountWithTokenKey.tokenKey, + ) + return token ? token.trim() : undefined + } catch { + return + } +} + +function isGuestToken(token: string) { + try { + const { username, email } = getDecodedToken(token) + const normalizedUsername = String(username).toLowerCase() + const normalizedEmail = String(email).toLowerCase() + return normalizedUsername === 'guest' || normalizedEmail === 'guest_user' + } catch { + return false + } +} + +function getSignedInApolloAccount(rootModel: ApolloRootModel) { + const signedInAccounts = getApolloInternetAccounts(rootModel).filter( + (account) => Boolean(getStoredToken(account)), + ) + const nonGuestSignedInAccount = signedInAccounts.find((account) => { + const token = getStoredToken(account) + return token ? !isGuestToken(token) : false + }) + return nonGuestSignedInAccount ?? signedInAccounts[0] +} + +function getDefaultApolloAccount(rootModel: ApolloRootModel) { + return getApolloInternetAccounts(rootModel).find(Boolean) +} + +function promptApolloLogin(account: ApolloInternetAccountModel) { + const maybePromptableAccount = account as ApolloInternetAccountModel & { + loginWithPrompt?: () => Promise + } + if (typeof maybePromptableAccount.loginWithPrompt === 'function') { + return maybePromptableAccount.loginWithPrompt() + } + return account.getToken() +} + +function isApolloSignedIn(rootModel: ApolloRootModel) { + return Boolean(getStoredToken(getSignedInApolloAccount(rootModel))) +} + +function getCurrentApolloUserLabel(rootModel: ApolloRootModel) { + const signedInAccount = getSignedInApolloAccount(rootModel) + const token = getStoredToken(signedInAccount) + if (!token) { + return 'Signed in as: not signed in' + } + try { + const { username, email } = getDecodedToken(token) + const identity = username || email || 'unknown user' + return `Signed in as: ${identity}` + } catch { + return 'Signed in as: token unreadable' + } +} + +function ApolloUserMenuLabel({ rootModel }: { rootModel: ApolloRootModel }) { + return getCurrentApolloUserLabel(rootModel) +} + +function notifyCurrentApolloUser( + rootModel: ApolloRootModel, + session: ApolloSessionModel, +) { + const signedInAccount = getSignedInApolloAccount(rootModel) + const sessionModel = session as unknown as AbstractSessionModel + const token = getStoredToken(signedInAccount) + if (!token) { + sessionModel.notify('Apollo login status: not signed in', 'warning') + return + } + try { + const { username, email, role } = getDecodedToken(token) + const identity = username || email || 'unknown user' + const roleText = role ? ` (${role})` : '' + sessionModel.notify(`Apollo login status: ${identity}${roleText}`, 'info') + } catch { + sessionModel.notify('Apollo login status: token unreadable', 'warning') + } +} + export function addTopLevelMenus(rootModel: AbstractMenuManager) { rootModel.insertInMenu( 'Apollo', @@ -42,20 +165,36 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { session.toggleLocked() }, }) - const { internetAccounts } = rootModel as unknown as ApolloRootModel - const hasApolloInternetAccount = internetAccounts.some((ia) => - isApolloInternetAccount(ia), - ) + const hasApolloInternetAccount = + getApolloInternetAccounts(rootModel as unknown as ApolloRootModel).length > + 0 if (hasApolloInternetAccount) { + rootModel.insertInMenu( + 'Apollo', + { + label: React.createElement(ApolloUserMenuLabel, { + rootModel: rootModel as unknown as ApolloRootModel, + }), + icon: AccountCircleIcon, + onClick: (session: ApolloSessionModel) => { + notifyCurrentApolloUser( + rootModel as unknown as ApolloRootModel, + session, + ) + }, + }, + 0, + ) + rootModel.appendToMenu('Apollo', { - label: 'Log out', - icon: LogoutIcon, + label: 'My workspace', + icon: FactCheckIcon, onClick: (session: ApolloSessionModel) => { ;(session as unknown as AbstractSessionModel).queueDialog( (doneCallback) => [ - LogOut, + MyAssemblyPermissions, { - session, + rootModel: rootModel as unknown as ApolloRootModel, handleClose: () => { doneCallback() }, @@ -64,5 +203,77 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { ) }, }) + + rootModel.appendToMenu('Apollo', { + label: 'Log in', + icon: AccountCircleIcon, + onClick: (session: ApolloSessionModel) => { + const apolloRootModel = rootModel as unknown as ApolloRootModel + const sessionModel = session as unknown as AbstractSessionModel + const defaultAccount = getDefaultApolloAccount(apolloRootModel) + if (!defaultAccount) { + sessionModel.notify( + 'Apollo login is unavailable: no Apollo account configured', + 'error', + ) + return + } + + void promptApolloLogin(defaultAccount) + .then((token) => { + try { + const { username, email, role } = getDecodedToken(token) + const identity = username || email || 'unknown user' + const roleText = role ? ` (${role})` : '' + sessionModel.notify( + `Apollo login successful: ${identity}${roleText}`, + 'success', + ) + // Rebuild session/view track selector state after auth changes. + // This avoids stale "Available tracks" panels after guest <-> user. + globalThis.location.reload() + return + } catch { + sessionModel.notify('Apollo login successful', 'success') + globalThis.location.reload() + return + } + }) + .catch((error: unknown) => { + if ( + error instanceof Error && + /user cancelled entry/i.test(error.message) + ) { + return + } + const message = + error instanceof Error ? error.message : 'Apollo login failed' + sessionModel.notify(message, 'error') + }) + }, + }) + + rootModel.appendToMenu('Apollo', { + label: 'Log out', + icon: LogoutIcon, + onClick: (session: ApolloSessionModel) => { + const apolloRootModel = rootModel as unknown as ApolloRootModel + const sessionModel = session as unknown as AbstractSessionModel + if (!isApolloSignedIn(apolloRootModel)) { + sessionModel.notify('Apollo is already signed out', 'info') + return + } + + sessionModel.queueDialog((doneCallback) => [ + LogOut, + { + rootModel: apolloRootModel, + handleClose: () => { + doneCallback() + }, + }, + ]) + }, + }) } } diff --git a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenuAdmin.ts b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenuAdmin.ts index 1f5ef4764..83997820d 100644 --- a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenuAdmin.ts +++ b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenuAdmin.ts @@ -5,6 +5,7 @@ import type { import AddIcon from '@mui/icons-material/Add' import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings' import DeleteIcon from '@mui/icons-material/Delete' +import EditIcon from '@mui/icons-material/Edit' import InputIcon from '@mui/icons-material/Input' import PersonIcon from '@mui/icons-material/Person' import RuleIcon from '@mui/icons-material/Rule' @@ -14,6 +15,7 @@ import { AddAssemblyAliases, AddRefSeqAliases, DeleteAssembly, + EditAssemblies, ImportFeatures, ManageChecks, ManageUsers, @@ -62,6 +64,23 @@ export function addTopLevelAdminMenus(rootModel: AbstractMenuManager) { ) }, }, + { + label: 'Edit Assemblies', + icon: EditIcon, + onClick: (session: ApolloSessionModel) => { + ;(session as unknown as AbstractSessionModel).queueDialog( + (doneCallback) => [ + EditAssemblies, + { + session, + handleClose: () => { + doneCallback() + }, + }, + ], + ) + }, + }, { label: 'Import Features', icon: InputIcon, diff --git a/packages/jbrowse-plugin-apollo/src/session/session.ts b/packages/jbrowse-plugin-apollo/src/session/session.ts index 2e4794003..593f63420 100644 --- a/packages/jbrowse-plugin-apollo/src/session/session.ts +++ b/packages/jbrowse-plugin-apollo/src/session/session.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, unicorn/consistent-function-scoping */ import { type AnnotationFeature, AnnotationFeatureModel, @@ -9,6 +10,7 @@ import { type JBrowseConfig, type UserLocation, filterJBrowseConfig, + getDecodedToken, } from '@apollo-annotation/shared' import type PluginManager from '@jbrowse/core/PluginManager' import type assemblyManager from '@jbrowse/core/assemblyManager' @@ -29,6 +31,7 @@ import { applySnapshot, getRoot, getSnapshot, + isAlive, types, } from '@jbrowse/mobx-state-tree' import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view' @@ -46,6 +49,34 @@ import { clientDataStoreFactory, } from './ClientDataStore' +function safeRetrieveToken(account: ApolloInternetAccountModel) { + try { + if (!isAlive(account)) { + return + } + return account.retrieveToken() ?? undefined + } catch { + return + } +} + +function getApolloInternetAccounts( + internetAccounts: ApolloRootModel['internetAccounts'], +) { + return internetAccounts.filter( + (internetAccount): internetAccount is ApolloInternetAccountModel => + isApolloInternetAccount(internetAccount), + ) +} + +function asAbstractSessionModel(model: unknown) { + return model as AbstractSessionModel +} + +function asLinearGenomeViewModel(view: unknown) { + return view as LinearGenomeViewModel +} + export interface ApolloSession extends AbstractSessionModel { apolloDataStore: ClientDataStoreModel apolloSelectedFeature?: AnnotationFeature @@ -65,10 +96,154 @@ export interface HoveredFeature { type Assembly = Instance>['assemblies'][0] +interface AssemblyPermissionResponse { + assemblyId?: string + assembly?: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +interface AssemblyResponse { + _id: string + name: string +} + export function extendSession( pluginManager: PluginManager, sessionModel: ReturnType, ) { + const normalizeConfigAssemblyNames = (config: JBrowseConfig) => { + interface ConfigAssembly { + name: string + displayName?: string + } + interface ConfigTrack { + assemblyNames?: string[] + [key: string]: unknown + } + + const assemblies = (config.assemblies ?? []) as unknown as ConfigAssembly[] + const displayNameToAssemblyName = new Map() + for (const assembly of assemblies) { + if (assembly.displayName && assembly.displayName !== assembly.name) { + displayNameToAssemblyName.set(assembly.displayName, assembly.name) + } + } + + const tracks = (config.tracks ?? []) as unknown as ConfigTrack[] + if (displayNameToAssemblyName.size === 0 || tracks.length === 0) { + return config + } + + const normalizedTracks = tracks.map((track: ConfigTrack) => { + if (!track.assemblyNames?.length) { + return track + } + const normalizedAssemblyNames = track.assemblyNames.map( + (assemblyName: string) => + displayNameToAssemblyName.get(assemblyName) ?? assemblyName, + ) + return { + ...track, + assemblyNames: normalizedAssemblyNames, + } + }) + + return { + ...config, + tracks: normalizedTracks, + } + } + + const isGuestToken = (token: string) => { + try { + const { username, email } = getDecodedToken(token) + return ( + username?.toLowerCase() === 'guest' || + email?.toLowerCase() === 'guest_user' + ) + } catch { + return false + } + } + + const removeApolloTracksFromSession = (session: AbstractSessionModel) => { + const sessionWithDelete = session as AbstractSessionModel & { + deleteTrackConf?: (conf: BaseTrackConfig) => void + } + const jbrowseWithDelete = getRoot( + session as unknown as ApolloSessionModel, + ).jbrowse as { + tracks?: BaseTrackConfig[] + deleteTrackConf?: (conf: BaseTrackConfig) => void + } + + const apolloTracks = new Map() + const collectApolloTracks = (trackList?: BaseTrackConfig[]) => { + for (const track of trackList ?? []) { + const trackType = (track as unknown as { type?: string }).type + const trackId = readConfObject(track, 'trackId') as string | undefined + if ( + trackType === 'ApolloTrack' || + trackId?.startsWith('apollo_track_') + ) { + apolloTracks.set(trackId ?? String(apolloTracks.size), track) + } + } + } + + collectApolloTracks([...session.tracks]) + collectApolloTracks(jbrowseWithDelete.tracks) + + for (const track of apolloTracks.values()) { + sessionWithDelete.deleteTrackConf?.(track) + jbrowseWithDelete.deleteTrackConf?.(track) + } + } + + const getViewableAssemblyNamesForAccount = async ( + internetAccount: ApolloInternetAccountModel, + signal: AbortSignal, + ) => { + const { baseURL } = internetAccount + const assembliesUri = new URL('assemblies', baseURL).href + const permissionsUri = new URL('assemblyPermissions/mine', baseURL).href + const apolloFetch = internetAccount.getFetcher({ + locationType: 'UriLocation', + uri: permissionsUri, + }) + + const [assembliesResponse, permissionsResponse] = await Promise.all([ + apolloFetch(assembliesUri, { method: 'GET', signal }), + apolloFetch(permissionsUri, { method: 'GET', signal }), + ]) + + if (!assembliesResponse.ok || !permissionsResponse.ok) { + return new Set() + } + + const assemblies = (await assembliesResponse.json()) as AssemblyResponse[] + const permissions = + (await permissionsResponse.json()) as AssemblyPermissionResponse[] + const assemblyIdToName = new Map( + assemblies.map((assembly) => [assembly._id, assembly.name]), + ) + + return new Set( + permissions + .filter( + (permission) => + permission.canViewAnnotations || permission.canEditAnnotations, + ) + .map((permission) => + assemblyIdToName.get( + permission.assemblyId ?? permission.assembly ?? '', + ), + ) + .filter(Boolean), + ) + } + const AnnotationFeatureExtended = pluginManager.evaluateExtensionPoint( 'Apollo-extendAnnotationFeature', AnnotationFeatureModel, @@ -119,7 +294,7 @@ export function extendSession( }, addApolloLocalTrackConfig(assembly: Assembly) { const trackId = `apollo_track_${assembly.name}` - const hasTrack = (self as unknown as AbstractSessionModel).tracks.some( + const hasTrack = asAbstractSessionModel(self).tracks.some( (track) => track.trackId === trackId, ) if (!hasTrack) { @@ -144,11 +319,8 @@ export function extendSession( }, getPluginConfiguration() { const { jbrowse } = getRoot(self) - const pluginConfiguration = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - jbrowse.configuration.ApolloPlugin as Instance< - typeof ApolloPluginConfigurationSchema - > + const pluginConfiguration = jbrowse.configuration + .ApolloPlugin as Instance return pluginConfiguration }, broadcastLocations() { @@ -159,11 +331,11 @@ export function extendSession( start: number end: number }[] = [] - for (const view of (self as unknown as AbstractSessionModel).views) { + for (const view of asAbstractSessionModel(self).views) { if (view.type !== 'LinearGenomeView') { return } - const lgv = view as unknown as LinearGenomeViewModel + const lgv = asLinearGenomeViewModel(view) if (lgv.initialized) { const { dynamicBlocks } = lgv for (const block of dynamicBlocks.contentBlocks) { @@ -179,28 +351,28 @@ export function extendSession( } } if (locations.length === 0) { - for (const internetAccount of internetAccounts) { - if (isApolloInternetAccount(internetAccount)) { - internetAccount.postUserLocation([]) - } + for (const internetAccount of getApolloInternetAccounts( + internetAccounts, + )) { + internetAccount.postUserLocation([]) } return } const allLocations: UserLocation[] = [] - for (const internetAccount of internetAccounts) { - if (isApolloInternetAccount(internetAccount)) { - for (const location of locations) { - const tmpLoc: UserLocation = { - assemblyId: location.assemblyName, - refSeq: location.refName, - start: location.start, - end: location.end, - } - allLocations.push(tmpLoc) + for (const internetAccount of getApolloInternetAccounts( + internetAccounts, + )) { + for (const location of locations) { + const tmpLoc: UserLocation = { + assemblyId: location.assemblyName, + refSeq: location.refName, + start: location.start, + end: location.end, } - internetAccount.postUserLocation(allLocations) + allLocations.push(tmpLoc) } + internetAccount.postUserLocation(allLocations) } }, })) @@ -232,12 +404,11 @@ export function extendSession( start: number end: number }[] = [] - for (const view of (self as unknown as AbstractSessionModel) - .views) { + for (const view of asAbstractSessionModel(self).views) { if (view.type !== 'LinearGenomeView') { return } - const lgv = view as unknown as LinearGenomeViewModel + const lgv = asLinearGenomeViewModel(view) if (lgv.initialized) { const { dynamicBlocks } = lgv for (const block of dynamicBlocks.contentBlocks) { @@ -254,28 +425,28 @@ export function extendSession( } } if (locations.length === 0) { - for (const internetAccount of internetAccounts) { - if (isApolloInternetAccount(internetAccount)) { - internetAccount.postUserLocation([]) - } + for (const internetAccount of getApolloInternetAccounts( + internetAccounts, + )) { + internetAccount.postUserLocation([]) } return } const allLocations: UserLocation[] = [] - for (const internetAccount of internetAccounts) { - if (isApolloInternetAccount(internetAccount)) { - for (const location of locations) { - const tmpLoc: UserLocation = { - assemblyId: location.assemblyName, - refSeq: location.refName, - start: location.start, - end: location.end, - } - allLocations.push(tmpLoc) + for (const internetAccount of getApolloInternetAccounts( + internetAccounts, + )) { + for (const location of locations) { + const tmpLoc: UserLocation = { + assemblyId: location.assemblyName, + refSeq: location.refName, + start: location.start, + end: location.end, } - internetAccount.postUserLocation(allLocations) + allLocations.push(tmpLoc) } + internetAccount.postUserLocation(allLocations) } }, { name: 'ApolloSessionBroadcastLocations' }, @@ -290,11 +461,10 @@ export function extendSession( // if any tracks are open. Here we copy the session snapshot, apply an // empty session snapshot, and then restore the original session // snapshot after the updated config.json loads. - const pluginConfiguration = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - jbrowse.configuration.ApolloPlugin as Instance< - typeof ApolloPluginConfigurationSchema - > + const pluginConfiguration = jbrowse.configuration + .ApolloPlugin as Instance< + typeof ApolloPluginConfigurationSchema + > const hasRole = readConfObject( pluginConfiguration, 'hasRole', @@ -306,8 +476,20 @@ export function extendSession( const hasApolloInternetAccount = internetAccounts.some((ia) => isApolloInternetAccount(ia), ) - const nonApolloAssemblies = ( - self as unknown as AbstractSessionModel + // Re-run this autorun after login/logout so role-gated config can be reloaded. + const apolloAuthSignature = internetAccounts + .filter((ia) => isAlive(ia)) + .filter((ia) => isApolloInternetAccount(ia)) + .map( + (ia) => + `${ia.internetAccountId}:${ia.role ?? ''}:${Boolean( + safeRetrieveToken(ia), + )}`, + ) + .join('|') + void apolloAuthSignature + const nonApolloAssemblies = asAbstractSessionModel( + self, ).assemblyManager.assemblies.filter( (a) => !( @@ -318,11 +500,36 @@ export function extendSession( ) if (!hasApolloInternetAccount || hasRole) { // Wait for assemblyManager to load before we do this part - const { assemblies } = (self as unknown as AbstractSessionModel) - .assemblyManager + const { assemblies } = + asAbstractSessionModel(self).assemblyManager if (assemblies.length === 0) { return } + + const sessionModel = asAbstractSessionModel(self) + const [signedInApolloAccount] = internetAccounts + .filter((ia): ia is ApolloInternetAccountModel => + isApolloInternetAccount(ia), + ) + .filter((ia) => Boolean(safeRetrieveToken(ia))) + .sort((a, b) => { + const aToken = safeRetrieveToken(a) + const bToken = safeRetrieveToken(b) + const aGuest = aToken ? isGuestToken(aToken) : true + const bGuest = bToken ? isGuestToken(bToken) : true + return Number(aGuest) - Number(bGuest) + }) + const signedInToken = signedInApolloAccount + ? safeRetrieveToken(signedInApolloAccount) + : undefined + + if (!signedInToken || isGuestToken(signedInToken)) { + self.apolloSetSelectedFeature() + removeApolloTracksFromSession(sessionModel) + reaction.dispose() + return + } + const { pluginConfiguration } = self.apolloDataStore const configuredOntologies = pluginConfiguration.ontologies as AnyConfigurationModel[] @@ -331,7 +538,6 @@ export function extendSession( readConfObject(ont, 'name') === featureTypeOntologyName, ) if (!featureTypeOntology) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call pluginConfiguration.addOntology({ name: 'Sequence Ontology', version: '01c33c6d9b6c8dca12e7d3e37b49ee113093c2fa', @@ -341,22 +547,32 @@ export function extendSession( }, }) } + + const viewableAssemblyNames = + await getViewableAssemblyNamesForAccount( + signedInApolloAccount, + self.abortController.signal, + ) + + self.apolloSetSelectedFeature() + // @ts-expect-error not sure why snapshot type is wrong for snapshot + applySnapshot(self, self.previousSnapshot) + removeApolloTracksFromSession(sessionModel) for (const a of nonApolloAssemblies) { + if (!viewableAssemblyNames.has(a.name)) { + continue + } self.addApolloLocalTrackConfig(a) } - // @ts-expect-error not sure why snapshot type is wrong for snapshot - applySnapshot(self, self.previousSnapshot) reaction.dispose() return } const { signal } = self.abortController // fetch and initialize assemblies for each of our Apollo internet accounts - for (const internetAccount of internetAccounts as ApolloInternetAccountModel[]) { - if (internetAccount.type !== 'ApolloInternetAccount') { - continue - } - + for (const internetAccount of getApolloInternetAccounts( + internetAccounts, + )) { const { baseURL } = internetAccount const uri = new URL('jbrowse/config.json', baseURL).href const fetch = internetAccount.getFetcher({ @@ -387,13 +603,16 @@ export function extendSession( console.error(error) continue } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!jbrowseConfig.configuration.ApolloPlugin.hasRole) { continue } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const normalizedJBrowseConfig = normalizeConfigAssemblyNames( + jbrowseConfig as JBrowseConfig, + ) + reloadPluginManagerCallback( - jbrowseConfig, + normalizedJBrowseConfig, self.previousSnapshot, ) reaction.dispose() @@ -447,10 +666,9 @@ export function extendSession( ...trackConfigSnapshot, trackId: newTrackId, } - for (const internetAccount of internetAccounts as ApolloInternetAccountModel[]) { - if (internetAccount.type !== 'ApolloInternetAccount') { - continue - } + for (const internetAccount of getApolloInternetAccounts( + internetAccounts, + )) { const change = new ImportJBrowseConfigChange({ typeName: 'ImportJBrowseConfigChange', oldJBrowseConfig: filteredConfig, @@ -472,7 +690,7 @@ export function extendSession( } // @ts-expect-error This method is missing in the JB types self.deleteTrackConf(conf) - // eslint-disable-next-line @typescript-eslint/no-unsafe-call + jbrowse.addTrackConf(newTrackConfigSnapshot) }, icon: SaveIcon, @@ -494,10 +712,9 @@ export function extendSession( const filteredTracks = filteredConfig?.tracks?.filter( (t) => t.trackId !== trackId, ) - for (const internetAccount of internetAccounts as ApolloInternetAccountModel[]) { - if (internetAccount.type !== 'ApolloInternetAccount') { - continue - } + for (const internetAccount of getApolloInternetAccounts( + internetAccounts, + )) { const change = new ImportJBrowseConfigChange({ typeName: 'ImportJBrowseConfigChange', oldJBrowseConfig: filteredConfig, @@ -515,7 +732,7 @@ export function extendSession( } // @ts-expect-error This method is missing in the JB types self.deleteTrackConf(conf) - // eslint-disable-next-line @typescript-eslint/no-unsafe-call + jbrowse.deleteTrackConf(conf) }, icon: SaveIcon, diff --git a/packages/jbrowse-plugin-apollo/src/types.ts b/packages/jbrowse-plugin-apollo/src/types.ts index 37fd6e1d7..cd8ab9daa 100644 --- a/packages/jbrowse-plugin-apollo/src/types.ts +++ b/packages/jbrowse-plugin-apollo/src/types.ts @@ -1,5 +1,6 @@ import type { BaseInternetAccountModel } from '@jbrowse/core/pluggableElementTypes' import type { AppRootModel } from '@jbrowse/core/util' +import { isAlive } from '@jbrowse/mobx-state-tree' import type { ApolloInternetAccountModel } from './ApolloInternetAccount/model' import type { ApolloSessionModel } from './session' @@ -12,5 +13,12 @@ export interface ApolloRootModel extends Omit { export function isApolloInternetAccount( internetAccount: BaseInternetAccountModel | ApolloInternetAccountModel, ): internetAccount is ApolloInternetAccountModel { - return internetAccount.type === 'ApolloInternetAccount' + try { + if (!isAlive(internetAccount)) { + return false + } + return internetAccount.type === 'ApolloInternetAccount' + } catch { + return false + } } diff --git a/packages/website/docs/02-installation/03-login-management.md b/packages/website/docs/02-installation/03-login-management.md index ba93c129c..20afda276 100644 --- a/packages/website/docs/02-installation/03-login-management.md +++ b/packages/website/docs/02-installation/03-login-management.md @@ -2,12 +2,69 @@ Apollo itself does not handle user logins. This lets us keep Apollo simpler and more secure, since it doesn't store passwords, but means you'll have to set up -third-party logins through Google or Microsoft. +third-party logins through Google, Microsoft, or Login.gov. Apollo also allows a single admin-level access root user with a password, usually for use with the CLI, and a passwordless guest user with configurable access level. +## Assembly ACL rollout + +Apollo now supports assembly-level annotation permissions in addition to global +roles. + +### Authorization model + +- Global role still gates broad capabilities (`none`, `readOnly`, `user`, + `admin`). +- Assembly permissions provide per-assembly view/edit grants. +- `admin` role bypasses assembly grants. +- `canEditAnnotations=true` always implies `canViewAnnotations=true`. + +### Recommended rollout sequence + +1. Verify at least one administrator account can access the server. +2. Upgrade Apollo server and plugin to versions that include assembly ACL + support. +3. Seed permissions for existing users and required assemblies. +4. Validate read and write behavior using representative users before broad + rollout. + +### Seeding permissions with CLI + +Use the CLI permissions commands as an admin: + +```sh +apollo permissions grant -u admin@example.org -a myAssembly --edit +apollo permissions grant -u annotator@example.org -a myAssembly --edit +apollo permissions grant -u reviewer@example.org -a myAssembly --view +apollo permissions list -u reviewer@example.org +``` + +To remove access: + +```sh +apollo permissions revoke -u reviewer@example.org -a myAssembly +``` + +### Backward compatibility notes + +- Existing deployments remain role-based if assembly permissions are not + created. +- Non-admin users without grants for a given assembly cannot read or write + Apollo annotations for that assembly. +- Existing non-Apollo tracks remain unaffected by assembly ACL. + +For a step-by-step production procedure, see +[Assembly ACL Rollout Runbook](./assembly-acl-rollout). + +## Login.gov status + +Login.gov support is scaffolded in Apollo3, but rollout should be treated as +pending until your final callback endpoint is registered/allowlisted with +Login.gov. Keep the provider disabled in production until endpoint approval is +complete. + In order to set up these logins, you'll need Apollo to be hosted at a domain name that you own (e.g. the "Public IPv4 DNS" of an AWS EC2 instance will not work). diff --git a/packages/website/docs/02-installation/04-configuration-options.md b/packages/website/docs/02-installation/04-configuration-options.md index 3b6a4d0a9..27bbed64b 100644 --- a/packages/website/docs/02-installation/04-configuration-options.md +++ b/packages/website/docs/02-installation/04-configuration-options.md @@ -29,10 +29,10 @@ SESSION_SECRET=g9fGaRuw06T7hs960Tm7KYyfcFaYEIaG9jfFnVEQ4QyFXmq7 # Alternatively, can be a path to a file with the session secret # SESSION_SECRET_FILE=/run/secrets/session-secret -############################################################################## -## To enable users to log in, you need either (or both) Google or Microsoft ## -## OAuth configured. Without them, only userless guest access is possible. ## -############################################################################## +#################################################################################### +## To enable users to log in, configure one or more providers: Google, Microsoft, ## +## or Login.gov OAuth/OIDC. Without them, only userless guest access is possible. ## +#################################################################################### # Google client id and secret. GOOGLE_CLIENT_ID=client_id_here @@ -50,6 +50,22 @@ MICROSOFT_CLIENT_SECRET=client_secret_here # Alternatively, can be a path to a file with the client secret # MICROSOFT_CLIENT_SECRET_FILE=/run/secrets/microsoft-client-secret +# Login.gov client id and secret. +# Endpoints default to the Login.gov production issuer when omitted. +# You must register/allowlist your exact callback endpoint before enabling this. +LOGINGOV_CLIENT_ID=client_id_here +# Alternatively, can be a path to a file with the client ID +# LOGINGOV_CLIENT_ID_FILE=/run/secrets/logingov-client-id +LOGINGOV_CLIENT_SECRET=client_secret_here +# Alternatively, can be a path to a file with the client secret +# LOGINGOV_CLIENT_SECRET_FILE=/run/secrets/logingov-client-secret +# Optional overrides for OIDC endpoints and scope +# LOGINGOV_ISSUER_BASE_URL=https://secure.login.gov +# LOGINGOV_AUTHORIZATION_URL=https://secure.login.gov/openid_connect/authorize +# LOGINGOV_TOKEN_URL=https://secure.login.gov/api/openid_connect/token +# LOGINGOV_USER_INFO_URL=https://secure.login.gov/api/openid_connect/userinfo +# LOGINGOV_SCOPE=openid email profile + ############## ## OPTIONAL ## ############## @@ -113,4 +129,9 @@ MICROSOFT_CLIENT_SECRET=client_secret_here # HTTP/HTTPS proxy for OAuth requests, if your server is behind a proxy # OAUTH_HTTP_PROXY=http://proxy.example.com:8080 + +# Assembly ACL is persisted in the database collection `assemblypermissions`. +# There is no dedicated environment toggle for this behavior in the current +# release. Manage grants via the Apollo UI (Manage Users) or CLI: +# apollo permissions list|grant|revoke ``` diff --git a/packages/website/docs/02-installation/05-assembly-acl-rollout.md b/packages/website/docs/02-installation/05-assembly-acl-rollout.md new file mode 100644 index 000000000..0338c8c74 --- /dev/null +++ b/packages/website/docs/02-installation/05-assembly-acl-rollout.md @@ -0,0 +1,73 @@ +# Assembly ACL Rollout Runbook + +This runbook describes how to safely enable and validate assembly-level +annotation permissions in Apollo. + +## Scope + +Assembly ACL controls access to Apollo annotation reads and writes per assembly, +per user. + +- Admin users bypass assembly grants. +- Non-admin users need grants per assembly. +- `canEditAnnotations=true` implies `canViewAnnotations=true`. + +## Preconditions + +Before rollout, verify: + +1. You have at least one admin account with confirmed login access. +2. Collaboration server and plugin builds include assembly ACL changes. +3. Database backup and rollback plan are available. +4. A test set of users and assemblies is prepared. + +## Cutover Checklist + +1. Deploy server and plugin versions that include assembly ACL endpoints and UI. +2. Confirm server health endpoint is healthy. +3. Seed initial assembly grants for existing users. +4. Validate representative role matrix: + - admin user can read/write all assemblies + - read-only user can read only granted assemblies + - user role can write only granted assemblies +5. Validate CLI permission commands against production config profile. +6. Announce cutover completion and provide support contact. + +## Seeding Grants (CLI) + +Examples: + +```sh +apollo permissions grant --profile admin -u admin@example.org -a asm1 asm2 --edit +apollo permissions grant --profile admin -u curator@example.org -a asm1 --edit +apollo permissions grant --profile admin -u reviewer@example.org -a asm1 --view +apollo permissions list --profile admin -u reviewer@example.org +``` + +## Verification Checklist + +Run these checks after seeding: + +1. `apollo permissions list` returns expected records. +2. `apollo feature get` only returns rows for assemblies user can view. +3. `apollo change get` only shows changes for assemblies user can view. +4. Submitting changes on unauthorized assemblies fails. +5. Submitting changes on authorized assemblies succeeds. + +## Rollback Checklist + +Use rollback if severe authorization regressions occur. + +1. Stop new writes from non-admin users (maintenance communication). +2. Roll back collaboration server and plugin to prior stable versions. +3. Verify health and core read paths. +4. Keep permission documents in DB unless schema rollback is required. +5. Re-run smoke checks for login, feature reads, and change submission. +6. Publish incident update with known impact and next window. + +## Post-Rollback Recovery + +1. Capture failing scenarios and user IDs/assemblies affected. +2. Reproduce in staging with the same grant dataset. +3. Patch and validate with CLI + UI checks. +4. Re-schedule rollout with a narrower canary set first. diff --git a/packages/website/docs/03-guides/04-cli/config.md b/packages/website/docs/03-guides/04-cli/config.md index 08f699992..e3c9d233a 100644 --- a/packages/website/docs/03-guides/04-cli/config.md +++ b/packages/website/docs/03-guides/04-cli/config.md @@ -30,8 +30,8 @@ DESCRIPTION Address and port e.g http://localhost:3999 - accessType: - How to access Apollo. accessType is typically one of: google, microsoft, guest, root. Allowed types depend on your - Apollo setup + How to access Apollo. accessType is typically one of: google, microsoft, logingov, guest, root. Allowed types depend + on your Apollo setup - accessToken: Access token. Usually inserted by `apollo login` diff --git a/packages/website/docs/03-guides/04-cli/help.md b/packages/website/docs/03-guides/04-cli/help.md index bf9170676..1041c5c49 100644 --- a/packages/website/docs/03-guides/04-cli/help.md +++ b/packages/website/docs/03-guides/04-cli/help.md @@ -23,4 +23,4 @@ DESCRIPTION ``` _See code: -[@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.8//workspaces/Apollo3/.yarn/cache/@oclif-plugin-help-npm-6.0.8-336726b8e7-4b3be03d8a.zip/node_modules/@oclif/plugin-help/lib/commands/help.ts)_ +[@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.8/src/commands/help.ts)_ diff --git a/packages/website/docs/03-guides/04-cli/index.md b/packages/website/docs/03-guides/04-cli/index.md index 13ecbf2f0..344d55cef 100644 --- a/packages/website/docs/03-guides/04-cli/index.md +++ b/packages/website/docs/03-guides/04-cli/index.md @@ -37,6 +37,9 @@ be configured for a different collaboration server. To use a profile other than the default one, pass the `--profile` flag with the profile name in the CLI command. +Administrators can also manage assembly-level annotation access using the +`apollo permissions` command group. + If you want to configure Apollo programmatically instead of using the interactive `apollo config` prompt, you can pass individual configuration values to the `apollo config` command as well. For example: diff --git a/packages/website/docs/03-guides/04-cli/permissions.md b/packages/website/docs/03-guides/04-cli/permissions.md new file mode 100644 index 000000000..4e41b122d --- /dev/null +++ b/packages/website/docs/03-guides/04-cli/permissions.md @@ -0,0 +1,105 @@ +# `apollo permissions` + +Commands to manage assembly permissions + +- [`apollo permissions grant`](#apollo-permissions-grant) +- [`apollo permissions list`](#apollo-permissions-list) +- [`apollo permissions revoke`](#apollo-permissions-revoke) + +## `apollo permissions grant` + +Grant assembly permissions to a user + +``` +USAGE + $ apollo permissions grant -u -a [--profile ] [--config-file ] [--view] [--edit] + +FLAGS + -a, --assembly=... (required) Assembly name or id + -u, --user= (required) User id, username, or email + --config-file= Use this config file (mostly for testing) + --edit Grant edit permission (implies view) + --profile= Use credentials from this profile + --view Grant view permission + +DESCRIPTION + Grant assembly permissions to a user + + Grants annotation permissions for one user across one or more assemblies. + +EXAMPLES + Grant view access to one assembly: + + $ apollo permissions grant -u user@example.org -a myAssembly --view + + Grant edit access to multiple assemblies (implies view): + + $ apollo permissions grant -u user@example.org -a asm1 asm2 --edit +``` + +_See code: +[src/commands/permissions/grant.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/grant.ts)_ + +## `apollo permissions list` + +List assembly permissions + +``` +USAGE + $ apollo permissions list [--profile ] [--config-file ] [-u ] [-a ] + +FLAGS + -a, --assembly= Filter by one assembly name or id + -u, --user= Filter by user id, username, or email + --config-file= Use this config file (mostly for testing) + --profile= Use credentials from this profile + +DESCRIPTION + List assembly permissions + + Lists assembly permission documents, optionally filtered by user and/or assembly. + +EXAMPLES + List all permissions: + + $ apollo permissions list + + List permissions for one user: + + $ apollo permissions list -u user@example.org + + List permissions for one assembly: + + $ apollo permissions list -a myAssembly +``` + +_See code: +[src/commands/permissions/list.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/list.ts)_ + +## `apollo permissions revoke` + +Revoke assembly permissions from a user + +``` +USAGE + $ apollo permissions revoke -u -a [--profile ] [--config-file ] + +FLAGS + -a, --assembly=... (required) Assembly name or id + -u, --user= (required) User id, username, or email + --config-file= Use this config file (mostly for testing) + --profile= Use credentials from this profile + +DESCRIPTION + Revoke assembly permissions from a user + + Revokes annotation permissions for one user across one or more assemblies. + +EXAMPLES + Revoke access from one assembly: + + $ apollo permissions revoke -u user@example.org -a myAssembly +``` + +_See code: +[src/commands/permissions/revoke.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/revoke.ts)_ diff --git a/yarn.lock b/yarn.lock index 2376f1a5c..a44d8b6d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -410,6 +410,7 @@ __metadata: generic-filehandle2: "npm:^2.0.18" https-proxy-agent: "npm:^7.0.6" jest: "npm:^29.6.2" + jest-util: "npm:^29.7.0" joi: "npm:^17.7.0" material-ui-popup-state: "npm:^5.0.4" mobx: "npm:^6.6.1" @@ -423,6 +424,7 @@ __metadata: passport-jwt: "npm:^4.0.0" passport-local: "npm:^1.0.0" passport-microsoft: "npm:^1.0.0" + passport-openidconnect: "npm:^0.1.2" prop-types: "npm:^15.8.1" quick-lru: "npm:^7.3.0" react: "npm:^18.2.0" @@ -538,7 +540,7 @@ __metadata: idb: "npm:^7.1.1" jest: "npm:^29.6.2" jest-fetch-mock: "npm:^3.0.3" - jest-util: "npm:^30.2.0" + jest-util: "npm:^29.7.0" jsonpath: "npm:^1.1.1" librpc-web-mod: "npm:^1.1.9" lodash.merge: "npm:^4.6.2" @@ -23317,7 +23319,7 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:30.2.0, jest-util@npm:^30.2.0": +"jest-util@npm:30.2.0": version: 30.2.0 resolution: "jest-util@npm:30.2.0" dependencies: @@ -26770,6 +26772,13 @@ __metadata: languageName: node linkType: hard +"oauth@npm:0.10.x": + version: 0.10.2 + resolution: "oauth@npm:0.10.2" + checksum: 10c0/5660d652b31eb2a90509989955a75b02311591d2e3cfb04a3fb91deae0d22c259aa5c31a9e8845f760d749af901032e3cfd06d151bbda9a5ebb9b76268d3aef9 + languageName: node + linkType: hard + "oauth@npm:0.9.x": version: 0.9.15 resolution: "oauth@npm:0.9.15" @@ -27557,6 +27566,16 @@ __metadata: languageName: node linkType: hard +"passport-openidconnect@npm:^0.1.2": + version: 0.1.2 + resolution: "passport-openidconnect@npm:0.1.2" + dependencies: + oauth: "npm:0.10.x" + passport-strategy: "npm:1.x.x" + checksum: 10c0/4d4662b91e2580baa7cf2f341904c352de9ec776a3897f8bf51a90f248ee7dab26b94e2e3c590d14821b04b0fa95cb8d7cfad6edc095e3966b7666e8779b0b68 + languageName: node + linkType: hard + "passport-strategy@npm:1.x.x, passport-strategy@npm:^1.0.0": version: 1.0.0 resolution: "passport-strategy@npm:1.0.0"