Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for user linked project permissions #147

Merged
merged 5 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/pr-release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
64 changes: 3 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
https://github.com/Aam-Digital/aam-services/tree/main/docs/developer
17 changes: 15 additions & 2 deletions src/auth/guards/jwt-bearer/jwt-bearer.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand All @@ -13,14 +15,25 @@ export class JwtBearerStrategy extends PassportStrategy(
Strategy,
'jwt-bearer',
) {
constructor(configService: ConfigService) {
constructor(
configService: ConfigService,
private couchdbService: CouchdbService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>(AuthModule.JWT_PUBLIC_KEY),
});
}

async validate(data: any): Promise<UserInfo> {
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'] : [],
);
Comment on lines +29 to +37
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small code block, but also for future proofing I would suggest we put the UserInfo creation in a service rather than duplicate it.

}
}
17 changes: 15 additions & 2 deletions src/auth/guards/jwt-cookie/jwt-cookie-strategy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -23,6 +28,14 @@ export class JwtCookieStrategy extends PassportStrategy(
}

async validate(data: any): Promise<UserInfo> {
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'] : [],
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions src/restricted-endpoints/session/user-auth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class UserInfo {
constructor(
public name: string,
public roles: string[],
public projects: string[] = [],
) {}
}

Expand Down
Loading