diff --git a/.github/workflows/pr-release.yml b/.github/workflows/pr-release.yml new file mode 100644 index 0000000..6ad4db0 --- /dev/null +++ b/.github/workflows/pr-release.yml @@ -0,0 +1,34 @@ +name: PR Release + +on: + pull_request: + +jobs: + build_and_publish_branch: + name: Build and publish replication-backend package + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + platforms: linux/amd64 + context: ./ + push: true + tags: ghcr.io/aam-digital/replication-backend:pr-${{ github.event.number }} diff --git a/README.md b/README.md index 1e33870..ba9c957 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ This backend service can be used to filter the replication between a [PouchDB](h It does this by overriding some of CouchDB`s endpoints where permissions are checked on the transmitted entities. The permission rules are defined through [CASL](https://casl.js.org/v5/en/). - ## Setup This API functions as a proxy layer between a client (PouchDB) and a standard CouchDB instance. The backend can either be run as a docker container @@ -33,66 +32,9 @@ In case the backend is run through Docker, the args can be provided like this ``` In case the backend is run through npm, the `.env` file can be adjusted. -## Defining permissions -The permissions are also stored in a CouchDB database (default database name `app`). -This can be the same database used for normal application data managed by users. - -The structure of the permission document is as follows: -```json -{ - "_id": "Config:Permissions", - "data": { - "public": [ - { "subject": "User", "action": "create"} - ], - "default": [ - { "subject": "Config", "action": "read" } - ], - "role_1": [ - { "subject": "all", "action": "manage"}, - ... - ], - "role_2": [], - ... - } -} -``` -Important is the exact `_id` as this is how the backend can find this document and that the rules config has the correct structure. - -The keys of the `data` object reference to roles that the different users can have and the values are arrays containing valid CASL [JSON rules](https://casl.js.org/v5/en/guide/define-rules#json-objects). -The rules at the value of the `default` key are prepended to other rules that are relevant for a user. -This allows to set user-agnostic rules, e.g. allowing everyone to read the `Config` document. -The default rules can be overwritten by user-specific rules. -The `public` rules are used when a user is **not** authenticated. -This allows to expose a public API, e.g. to integrate a public form. -Subjects refer to the prefixes of the `_id` properties of documents e.g. `_id: Child:123` refers to subject `Child`. -The `all` subject is a wildcard that refers to all documents. - -The actions can be: -* `create` -* `read` -* `update` -* `delete` -* `manage` (which is a wildcard for any action) - -It is also possible to access information of the user sending the request. E.g.: - -```json -{ - "subject": "org.couchdb.user", - "action": "update", - "fields": [ - "password" - ], - "conditions": { - "name": "${user.name}" - } -} -``` -This allows users to update the `password` property of their *own* document in the `_users` database. -Another available value is `${user.roles}` which is an array of rules which the user has. +## Defining Permissions -For more information on how to write rules have a look at the [CASL documentation](https://casl.js.org/v5/en/guide/intro). +See our [Developer Documentation](https://aam-digital.github.io/ndb-core/documentation/additional-documentation/concepts/user-roles-and-permissions.html) ## Operation Besides the CouchDB endpoints, the backend also provides some additional endpoints that are necessary to be used at times. @@ -114,4 +56,4 @@ To run and test this project locally: ## Run in a fully local environment with other services Use the dockerized local environment to run a fully synced app including backend services on your machine: -https://github.com/Aam-Digital/aam-services/tree/main/docs/developer \ No newline at end of file +https://github.com/Aam-Digital/aam-services/tree/main/docs/developer diff --git a/src/auth/guards/jwt-bearer/jwt-bearer.strategy.ts b/src/auth/guards/jwt-bearer/jwt-bearer.strategy.ts index 0d37d2d..93dd0be 100644 --- a/src/auth/guards/jwt-bearer/jwt-bearer.strategy.ts +++ b/src/auth/guards/jwt-bearer/jwt-bearer.strategy.ts @@ -4,6 +4,8 @@ import { Injectable } from '@nestjs/common'; import { UserInfo } from '../../../restricted-endpoints/session/user-auth.dto'; import { ConfigService } from '@nestjs/config'; import { AuthModule } from '../../auth.module'; +import { CouchdbService } from '../../../couchdb/couchdb.service'; +import { firstValueFrom } from 'rxjs'; /** * Authenticate a user with a foreign bearer JWT using the {@link AuthModule.JWT_PUBLIC_KEY}. @@ -13,7 +15,10 @@ export class JwtBearerStrategy extends PassportStrategy( Strategy, 'jwt-bearer', ) { - constructor(configService: ConfigService) { + constructor( + configService: ConfigService, + private couchdbService: CouchdbService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: configService.get(AuthModule.JWT_PUBLIC_KEY), @@ -21,6 +26,14 @@ export class JwtBearerStrategy extends PassportStrategy( } async validate(data: any): Promise { - return new UserInfo(data.username, data['_couchdb.roles']); + const user = await firstValueFrom( + this.couchdbService.get('app', data['username']), + ).catch(() => {}); + + return new UserInfo( + data.username, + data['_couchdb.roles'], + user && user['projects'] ? user['projects'] : [], + ); } } diff --git a/src/auth/guards/jwt-cookie/jwt-cookie-strategy.service.ts b/src/auth/guards/jwt-cookie/jwt-cookie-strategy.service.ts index d197fda..a5b9e1b 100644 --- a/src/auth/guards/jwt-cookie/jwt-cookie-strategy.service.ts +++ b/src/auth/guards/jwt-cookie/jwt-cookie-strategy.service.ts @@ -5,6 +5,8 @@ import { AuthModule } from '../../auth.module'; import { UserInfo } from '../../../restricted-endpoints/session/user-auth.dto'; import { TOKEN_KEY } from '../../cookie/cookie.service'; import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { CouchdbService } from '../../../couchdb/couchdb.service'; /** * Authenticate a user using an existing JWT from a cookie in the request. @@ -14,7 +16,10 @@ export class JwtCookieStrategy extends PassportStrategy( Strategy, 'jwt-cookie', ) { - constructor(configService: ConfigService) { + constructor( + configService: ConfigService, + private couchdbService: CouchdbService, + ) { super({ jwtFromRequest: (req) => req?.cookies[TOKEN_KEY], ignoreExpiration: false, @@ -23,6 +28,14 @@ export class JwtCookieStrategy extends PassportStrategy( } async validate(data: any): Promise { - return new UserInfo(data.name, data.sub); + const user = await firstValueFrom( + this.couchdbService.get('app', data['username']), + ).catch(() => {}); + + return new UserInfo( + data.name, + data.sub, + user && user['projects'] ? user['projects'] : [], + ); } } diff --git a/src/restricted-endpoints/document/document.controller.spec.ts b/src/restricted-endpoints/document/document.controller.spec.ts index 7fad589..8478dd5 100644 --- a/src/restricted-endpoints/document/document.controller.spec.ts +++ b/src/restricted-endpoints/document/document.controller.spec.ts @@ -304,9 +304,10 @@ describe('DocumentController', () => { }); it('should throw exception if the update permission is not given', () => { - const otherUser = { + const otherUser: UserInfo = { name: 'anotherUser', roles: [], + projects: [], }; mockAbility([ { diff --git a/src/restricted-endpoints/replication/changes/changes.controller.spec.ts b/src/restricted-endpoints/replication/changes/changes.controller.spec.ts index 9f154a6..ef8a8fa 100644 --- a/src/restricted-endpoints/replication/changes/changes.controller.spec.ts +++ b/src/restricted-endpoints/replication/changes/changes.controller.spec.ts @@ -24,7 +24,7 @@ describe('ChangesController', () => { childDoc, deletedChildDoc, ]); - const user: UserInfo = { name: 'username', roles: [] }; + const user: UserInfo = { name: 'username', roles: [], projects: [] }; const mockCouchdbService = { get: () => undefined } as CouchdbService; const getSpy = jest.spyOn(mockCouchdbService, 'get'); const mockRulesService = { diff --git a/src/restricted-endpoints/session/user-auth.dto.ts b/src/restricted-endpoints/session/user-auth.dto.ts index bb76c71..4a12be1 100644 --- a/src/restricted-endpoints/session/user-auth.dto.ts +++ b/src/restricted-endpoints/session/user-auth.dto.ts @@ -13,6 +13,7 @@ export class UserInfo { constructor( public name: string, public roles: string[], + public projects: string[] = [], ) {} }