Skip to content

Commit

Permalink
Merge pull request #47 from mareklibra/FLPATH-809.Authentication
Browse files Browse the repository at this point in the history
 FLPATH-809: get the user via Identity API
  • Loading branch information
ydayagi authored Jan 3, 2024
2 parents 781b37e + 0a413fd commit 0ea9a45
Show file tree
Hide file tree
Showing 13 changed files with 350 additions and 181 deletions.
90 changes: 76 additions & 14 deletions plugins/notifications-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ It's backed by a relational database, so far tested with PostgreSQL.

## Getting started

### Prerequisities
### Prerequisites

- Install [PostgresSQL DB](https://www.postgresql.org/download/)
- Configure Postgres for tcp/ip
Expand Down Expand Up @@ -54,10 +54,19 @@ export default async function createPlugin(
): Promise<Router> {
const catalogClient = new CatalogClient({ discoveryApi: env.discovery });
const dbConfig = env.config.getConfig('backend.database');
// Following is optional
const externalCallerSecret = env.config.getOptionalString(
'notifications.externalCallerSecret',
);
return await createRouter({
identity: env.identity,
logger: env.logger,
permissions: env.permissions,
tokenManager: env.tokenManager,
dbConfig,
catalogClient,
externalCallerSecret,
});
}
```
Expand Down Expand Up @@ -103,9 +112,61 @@ In the `app-config.yaml` or `app-config.local.yaml`:
store: memory
```

#### Other configuration (optional):

If you have issues to create valid JWT tokens by an external caller, use following option to bypass the service-to-service configuration for them:

```
notifications:
# Workaround for issues with external caller JWT token creation.
# When following config option is not provided and the request "authentication" header is missing, the request is ALLOWED by default
# When following option is present, the request must contain either a valid JWT token or that provided shared secret in the "notifications-secret" header
externalCallerSecret: your-secret-token-shared-with-external-services
```

Mind using HTTPS to help preventing leaking the shared secret.

Example of the request then:

```
curl -X POST http://localhost:7007/api/notifications/notifications -H "Content-Type: application/json" -H "notifications-secret: your-secret-token-shared-with-external-services" -d '{"title":"my-title","origin":"my-origin","message":"message one","topic":"my-topic"}'
```

Notes:

- The `externalCallerSecret` is an workaround and will be probably replaced by proper use of JWT tokens.
- Sharing the same shared secret with the "auth.secret" option is not recommended

#### Authentication

Please refer https://backstage.io/docs/auth/ to set-up authentication.

The Notifications flows are based on the identity of the user.

All targetUsers, targetGroups or signed-in users receiving notifications must have corresponding entities created in the Catalog.
Refer https://backstage.io/docs/auth/identity-resolver for details.

For the purpose of development, there is `users.yaml` listing example data created.

#### Authorization

Every service endpoint is guarded by a permission check, enabled by default.

It is up to particular deployment to provide corresponding permission policies based on https://backstage.io/docs/permissions/writing-a-policy. To register your permission policies, refer https://backstage.io/docs/permissions/getting-started#integrating-the-permission-framework-with-your-backstage-instance.

#### Service-to-service and External Calls

The notification-backend is expected to be called by FE plugins (including the notifications-frontend), other backend plugins or external services.

To configure those two flows, refer

- https://backstage.io/docs/auth/service-to-service-auth.
- https://backstage.io/docs/auth/service-to-service-auth#usage-in-external-callers

#### Catalog

The notifications require affected users or groups (as receivers) to be listed in the Catalog.
The notifications require target users or groups (as receivers) to be listed in the Catalog.

As an example how to do it, add following to the config:

Expand All @@ -132,7 +193,7 @@ See `src/openapi.yaml` for full OpenAPI spec.

### Posting a notification

A notification without users or groups is considered a system notification. That means it is is intended for all users.
A notification without target users or groups is considered a system notification. That means it is intended for all users (listed among Updates in the UI).

Request (User message and then system message):

Expand All @@ -144,6 +205,8 @@ curl -X POST http://localhost:7007/api/notifications/notifications -H "Content-T
curl -X POST http://localhost:7007/api/notifications/notifications -H "Content-Type: application/json" -d '{"title": "My message title", "message": "I have nothing to say", "origin": "my-origin", "actions": [{"title": "my-title", "url": "http://foo.bar"}, {"title": "another action", "url": "https://foo.foo.bar"}]}'
```

Optionally add `-H "Authorization: Bearer eyJh.....` with a valid JWT token if the service-to-service authorization is enabled (see above).

Response:

```json
Expand All @@ -170,7 +233,7 @@ Query parameters:
Request:

```bash
curl 'http://localhost:7007/api/notifications/notifications?user=loggedinuser&read=false&pageNumber=0&pageSize=0'
curl 'http://localhost:7007/api/notifications/notifications?read=false&pageNumber=0&pageSize=0'
```

Response:
Expand All @@ -195,9 +258,15 @@ Response:

User parameter is mandatory because it is needed for filtering (read/unread).

**Important: Logged-in user:**

The query requires a signed-in user whose entity is listed in the Catalog.
With this condition is met, the HTTP `Authorization` header contains a JWT token with the user's identity.

Optionally add `-H "Authorization: Bearer eyJh.....` with a valid JWT token to the `curl` commands bellow.

Query parameters:

- user. name of user to retrieve notification for
- containsText. filter title and message containing this text (case insensitive)
- createdAfter. fetch notifications created after this point in time
- messageScope. all/user/system. fetch notifications intended for specific user or system notifications or both
Expand All @@ -206,7 +275,7 @@ Query parameters:
Request:

```bash
curl http://localhost:7007/api/notifications/notifications/count?user=loggedinuser
curl http://localhost:7007/api/notifications/notifications/count
```

Response:
Expand All @@ -220,18 +289,11 @@ Response:
Request:

```bash
curl -X PUT 'http://localhost:7007/api/notifications/notifications/read?messageID=48bbf896-4b7c-4b68-a446-246b6a801000&user=dummy&read=true'
curl -X PUT 'http://localhost:7007/api/notifications/notifications/read?messageID=48bbf896-4b7c-4b68-a446-246b6a801000&read=true'
```

Response: just HTTP status

## Users

A user the notifications are filtered for, all targetUsers or targetGroups must have corresponding entities created in the Catalog.
Refer [Backstage documentation](https://backstage.io/docs/auth/) for details.

For the purpose of development, there is `users.yaml` listing example data.

## Building a client for the API

We supply an Open API spec YAML file: openapi.yaml.
4 changes: 4 additions & 0 deletions plugins/notifications-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"@backstage/backend-openapi-utils": "^0.1.0",
"@backstage/catalog-client": "^1.4.5",
"@backstage/config": "^1.1.1",
"@backstage/errors": "^1.2.3",
"@backstage/plugin-auth-node": "^0.4.0",
"@backstage/plugin-permission-common": "0.7.9",
"@backstage/plugin-permission-node": "0.7.17",
"@types/express": "*",
"ajv-formats": "^2.1.1",
"express": "^4.18.2",
Expand Down
1 change: 1 addition & 0 deletions plugins/notifications-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './service/router';
export * from './service/permissions';
22 changes: 2 additions & 20 deletions plugins/notifications-backend/src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,14 @@ paths:
format: date-time
- name: messageScope
in: query
description: retrieve either user messages, system messages or both
description: retrieve either logged-in user messages, system messages or both
required: false
schema:
type: string
enum:
- all
- user
- system
- name: user
in: query
description: User for whom notifications will be fetched
required: false
schema:
type: string
- name: read
in: query
description: Notifications read or not
Expand Down Expand Up @@ -142,20 +136,14 @@ paths:
format: date-time
- name: messageScope
in: query
description: retrieve either user messages, system messages or both
description: retrieve either logged-in user messages, system messages or both
required: false
schema:
type: string
enum:
- all
- user
- system
- name: user
in: query
description: User for whom notifications will be fetched
required: false
schema:
type: string
- name: read
in: query
description: Notifications read or not
Expand Down Expand Up @@ -187,12 +175,6 @@ paths:
required: true
schema:
type: string
- name: user
in: query
description: The user
required: true
schema:
type: string
- name: read
in: query
description: read/unread
Expand Down
110 changes: 110 additions & 0 deletions plugins/notifications-backend/src/service/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import {
AuthorizeResult,
BasicPermission,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';

import express from 'express';

import { DefaultServiceUser } from './constants';
import { RouterOptions } from './types';

export type GetLoggedInUserOptions = Pick<
RouterOptions,
'identity' | 'tokenManager' | 'externalCallerSecret'
>;
export type CheckUserPermission = GetLoggedInUserOptions &
Pick<RouterOptions, 'permissions'>;

/*
* User's entity must be present in the catalog.
*/
export const getLoggedInUser = async (
request: express.Request,
{ identity, tokenManager, externalCallerSecret }: GetLoggedInUserOptions,
): Promise<string> => {
const identityResponse = await identity.getIdentity({ request });

// To properly set identity, see packages/backend/src/plugins/auth.ts or https://backstage.io/docs/auth/identity-resolver
if (identityResponse) {
// The auth token contains user's identity, most probably originated in the FE
// Example: user:default/guest
let author = identityResponse?.identity.userEntityRef;
if (author) {
if (author.startsWith('user:')) {
author = author.slice('user:'.length);
}
} else {
throw new AuthenticationError(
'Missing valid authentication data or the user is not in the Catalog.',
);
}
return author;
}

const token = getBearerTokenFromAuthorizationHeader(
request.header('authorization'),
);
if (token) {
// backend service-to-service flow
await tokenManager.authenticate(token);
}

// External call - workaround
// Following shared-secret is a workaround till we make the creation of valid JWT tokens by external callers simple.
// In such case, the flow would be identical with the service-to-service.
// https://github.com/backstage/backstage/issues/18622
// https://github.com/backstage/backstage/issues/9374
if (externalCallerSecret) {
if (request.header('notifications-secret') === externalCallerSecret) {
return DefaultServiceUser;
}
throw new AuthenticationError('Provided shared secret does not match.');
}

// Since the shared secret has not been requested in the configuration, we ALLOW the request
return DefaultServiceUser;
};

export const checkPermission = async (
request: express.Request,
permissions: PermissionEvaluator,
permission: BasicPermission,
loggedInUser: string,
) => {
const token = getBearerTokenFromAuthorizationHeader(
request.header('authorization'),
);
const decision = (
await permissions.authorize([{ permission }], {
token,
})
)[0];

if (decision.result === AuthorizeResult.DENY) {
throw new NotAllowedError(
`The user ${loggedInUser} is not authorized to ${permission.name}`,
);
}
};

/**
* Checks if the logged-in user has the required permission
* and returns the username.
*/
export const checkUserPermission = async (
request: express.Request,
options: CheckUserPermission,
requiredPermission: BasicPermission,
): Promise<string> => {
const loggedInUser = await getLoggedInUser(request, options);
await checkPermission(
request,
options.permissions,
requiredPermission,
loggedInUser,
);
return loggedInUser;
};
6 changes: 6 additions & 0 deletions plugins/notifications-backend/src/service/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const DefaultServiceUser = 'default/externalServiceNotificationsUser';
export const DefaultMessageScope = 'user';
export const DefaultPageNumber = 1;
export const DefaultPageSize = 20;
export const DefaultOrderBy = 'created';
export const DefaultOrderDirection = 'desc';
Loading

0 comments on commit 0ea9a45

Please sign in to comment.