Merge remote-tracking branch 'origin/master' into task-action-idempotency
* origin/master: (176 commits) chore: filter timestamp on different DB engines chore: add unit tests for the createdAfter filter of notifications feat: add createdAfter filtering to the Notifications kubernetes-backend: auth test fix backend-app-api: review fixes for cookie auth backend-plugin-api: updated cookie auth implementation backend-test-utils: update mockCredentials for cookie auth backend-plugin-api: add AuthService.getNoneCredentials backend-plugin-api: add support for limited user tokens feat: update Notifications front-end feat: need an api report 🎉 Signed-off-by: blam <ben@blam.sh> chore: small cleanup wip wip wip wip wip wip wip Updated changeset ... Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
---
|
||||
'@backstage/plugin-events-backend-test-utils': patch
|
||||
'@backstage/plugin-events-backend': patch
|
||||
'@backstage/plugin-events-node': patch
|
||||
---
|
||||
|
||||
Add new `EventsService` as well as `eventsServiceRef` for the new backend system.
|
||||
|
||||
**Summary:**
|
||||
|
||||
- new:
|
||||
`EventsService`, `eventsServiceRef`, `TestEventsService`
|
||||
- deprecated:
|
||||
`EventBroker`, `EventPublisher`, `EventSubscriber`, `DefaultEventBroker`, `EventsBackend`,
|
||||
most parts of `EventsExtensionPoint` (alpha),
|
||||
`TestEventBroker`, `TestEventPublisher`, `TestEventSubscriber`
|
||||
|
||||
Add the `eventsServiceRef` as dependency to your backend plugins
|
||||
or backend plugin modules.
|
||||
|
||||
**Details:**
|
||||
|
||||
The previous implementation using the `EventsExtensionPoint` was added in the early stages
|
||||
of the new backend system and does not respect the plugin isolation.
|
||||
This made it not compatible anymore with the new backend system.
|
||||
|
||||
Additionally, the previous interfaces had some room for simplification,
|
||||
supporting less exposure of internal concerns as well.
|
||||
|
||||
Hereby, this change adds a new `EventsService` interface as replacement for the now deprecated `EventBroker`.
|
||||
The new interface does not require any `EventPublisher` or `EventSubscriber` interfaces anymore.
|
||||
Instead, it is expected that the `EventsService` gets passed into publishers and subscribers,
|
||||
and used internally. There is no need to expose anything of that at their own interfaces.
|
||||
|
||||
Most parts of `EventsExtensionPoint` (alpha) are deprecated as well and were not usable
|
||||
(by other plugins or their modules) anyway.
|
||||
|
||||
The `DefaultEventBroker` implementation is deprecated and wraps the new `DefaultEventsService` implementation.
|
||||
Optionally, an instance can be passed as argument to allow mixed setups to operate alongside.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-adr': patch
|
||||
'@backstage/plugin-adr-common': patch
|
||||
---
|
||||
|
||||
Fixed Azure DevOps ADR file path reading
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-guest-provider': minor
|
||||
---
|
||||
|
||||
Adds a new guest provider that maps guest users to actual tokens. This also shifts the default guest login to `user:development/guest` to reduce overlap with your production/real data. To change that (or set it back to the old default, use the new `auth.providers.guest.userEntityRef` config key) like so,
|
||||
|
||||
```yaml title=app-config.yaml
|
||||
auth:
|
||||
providers:
|
||||
guest:
|
||||
userEntityRef: user:default/guest
|
||||
```
|
||||
|
||||
This also adds a new property to control the ownership entity refs,
|
||||
|
||||
```yaml title=app-config.yaml
|
||||
auth:
|
||||
providers:
|
||||
guest:
|
||||
ownershipEntityRefs:
|
||||
- guests
|
||||
- development/custom
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/errors': patch
|
||||
---
|
||||
|
||||
Fixed an issue that was causing ResponseError not to report the HTTP status from the provided response.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-playlist-backend': patch
|
||||
---
|
||||
|
||||
Migrated to support new auth services.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-code-coverage-backend': patch
|
||||
---
|
||||
|
||||
Fix jacoco convertor to not require annotation to be set to scm-only.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Added `mockServices.userInfo`, which now also automatically is made available in test backends.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/theme': patch
|
||||
---
|
||||
|
||||
Exported `defaultTypography` to make adjusting these values in a custom theme easier
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Removed hardcoded font-family on select input
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-notifications-backend': patch
|
||||
'@backstage/plugin-notifications': patch
|
||||
---
|
||||
|
||||
The Notifications can be newly filtered based on the Created Date.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-azure-sites-backend': patch
|
||||
---
|
||||
|
||||
Added new backend system for the Azure Sites backend plugin
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration-react': patch
|
||||
---
|
||||
|
||||
Updated `microsoftAuthApi` scopes for Azure DevOps to be fully qualified.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-node': patch
|
||||
---
|
||||
|
||||
Fix handling of default plugins that have configuration
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-code-coverage-backend': patch
|
||||
---
|
||||
|
||||
Migrated to support new auth services.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Create a component abstraction to consume system icons.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-azure-sites-backend': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `createRouter` method now requires the `discovery` service to be forwarded from the plugin environment. This is part of the migration to support new auth services.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-jenkins-backend': minor
|
||||
---
|
||||
|
||||
**BREAKING**: Both `createRouter` and `DefaultJenkinsInfoProvider.fromConfig` now require the `discovery` service to be forwarded from the plugin environment. This is part of the migration to support new auth services.
|
||||
|
||||
The `JenkinsInfoProvider` interface has been updated to receive `credentials` of the type `BackstageCredentials` rather than a token.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
Added 'root' and 'label' class keys for EntityAutocompletePicker, EntityOwnerPicker and EntityProcessingStatusPicker
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-confluence-to-markdown': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-bitbucket-server': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-cookiecutter': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-bitbucket': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-gerrit': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-github': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-sentry': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-yeoman': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-azure': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-gitea': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-rails': patch
|
||||
'@backstage/plugin-scaffolder-node-test-utils': minor
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Introduced `createMockActionContext` to unify the way of creating scaffolder mock context.
|
||||
|
||||
It will help to maintain tests in a long run during structural changes of action context.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
'@backstage/plugin-events-backend-module-bitbucket-cloud': minor
|
||||
'@backstage/plugin-events-backend-module-gerrit': minor
|
||||
'@backstage/plugin-events-backend-module-github': minor
|
||||
'@backstage/plugin-events-backend-module-gitlab': minor
|
||||
'@backstage/plugin-events-backend-module-azure': minor
|
||||
'@backstage/plugin-events-node': minor
|
||||
---
|
||||
|
||||
BREAKING CHANGE: Migrate `EventRouter` implementations from `EventBroker` to `EventsService`.
|
||||
|
||||
`EventRouter` uses the new `EventsService` instead of the `EventBroker` now,
|
||||
causing a breaking change to its signature.
|
||||
|
||||
All of its extensions and implementations got adjusted accordingly.
|
||||
(`SubTopicEventRouter`, `AzureDevOpsEventRouter`, `BitbucketCloudEventRouter`,
|
||||
`GerritEventRouter`, `GithubEventRouter`, `GitlabEventRouter`)
|
||||
|
||||
Required adjustments were made to all backend modules for the new backend system,
|
||||
now also making use of the `eventsServiceRef` instead of the `eventsExtensionPoint`.
|
||||
|
||||
**Migration:**
|
||||
|
||||
Example for implementations of `SubTopicEventRouter`:
|
||||
|
||||
```diff
|
||||
import {
|
||||
EventParams,
|
||||
+ EventsService,
|
||||
SubTopicEventRouter,
|
||||
} from '@backstage/plugin-events-node';
|
||||
|
||||
export class GithubEventRouter extends SubTopicEventRouter {
|
||||
- constructor() {
|
||||
- super('github');
|
||||
+ constructor(options: { events: EventsService }) {
|
||||
+ super({
|
||||
+ events: options.events,
|
||||
+ topic: 'github',
|
||||
+ });
|
||||
}
|
||||
|
||||
+ protected getSubscriberId(): string {
|
||||
+ return 'GithubEventRouter';
|
||||
+ }
|
||||
+
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Example for a direct extension of `EventRouter`:
|
||||
|
||||
```diff
|
||||
class MyEventRouter extends EventRouter {
|
||||
- constructor(/* ... */) {
|
||||
+ constructor(options: {
|
||||
+ events: EventsService;
|
||||
+ // ...
|
||||
+ }) {
|
||||
- super();
|
||||
// ...
|
||||
+ super({
|
||||
+ events: options.events,
|
||||
+ topics: topics,
|
||||
+ });
|
||||
}
|
||||
+
|
||||
+ protected getSubscriberId(): string {
|
||||
+ return 'MyEventRouter';
|
||||
+ }
|
||||
-
|
||||
- supportsEventTopics(): string[] {
|
||||
- return this.topics;
|
||||
- }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-linguist': patch
|
||||
---
|
||||
|
||||
Get component's title from translation file. See: https://backstage.io/docs/plugins/internationalization#for-an-application-developer-overwrite-plugin-messages
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
The entity page extension provided by the `/alpha` plugin now correctly renders the entity 404 page.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
'@backstage/plugin-events-backend-module-aws-sqs': minor
|
||||
---
|
||||
|
||||
BREAKING CHANGE: Migrate `AwsSqsConsumingEventPublisher` and its backend module to use `EventsService`.
|
||||
|
||||
Uses the `EventsService` instead of `EventBroker` at `AwsSqsConsumingEventPublisher`,
|
||||
dropping the use of `EventPublisher` including `setEventBroker(..)`.
|
||||
|
||||
Now, `AwsSqsConsumingEventPublisher.fromConfig` requires `events: EventsService` as option.
|
||||
|
||||
```diff
|
||||
const sqs = AwsSqsConsumingEventPublisher.fromConfig({
|
||||
config: env.config,
|
||||
+ events: env.events,
|
||||
logger: env.logger,
|
||||
scheduler: env.scheduler,
|
||||
});
|
||||
+ await Promise.all(sqs.map(publisher => publisher.start()));
|
||||
|
||||
// e.g. at packages/backend/src/plugins/events.ts
|
||||
- await new EventsBackend(env.logger)
|
||||
- .setEventBroker(env.eventBroker)
|
||||
- .addPublishers(sqs)
|
||||
- .start();
|
||||
|
||||
// or for other kinds of setups
|
||||
- await Promise.all(sqs.map(publisher => publisher.setEventBroker(eventBroker)));
|
||||
```
|
||||
|
||||
`eventsModuleAwsSqsConsumingEventPublisher` uses the `eventsServiceRef` as dependency,
|
||||
instead of `eventsExtensionPoint`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Added the `UserInfoApi` as both an optional input and as an output for `createLegacyAuthAdapters`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Removed the inline color from select icon to allow it to be colored via a theme
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend-module-techdocs': patch
|
||||
'@backstage/plugin-search-backend-module-catalog': patch
|
||||
'@backstage/plugin-search-backend-module-explore': patch
|
||||
---
|
||||
|
||||
Migrated to support new auth services.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
'@backstage/plugin-catalog-node': minor
|
||||
---
|
||||
|
||||
Allow setting EntityDataParser using CatalogModelExtensionPoint
|
||||
@@ -0,0 +1,280 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"tag": "next",
|
||||
"initialVersions": {
|
||||
"example-app": "0.2.92",
|
||||
"@backstage/app-defaults": "1.5.0",
|
||||
"example-app-next": "0.0.6",
|
||||
"app-next-example-plugin": "0.0.6",
|
||||
"example-backend": "0.2.92",
|
||||
"@backstage/backend-app-api": "0.5.11",
|
||||
"@backstage/backend-common": "0.21.0",
|
||||
"@backstage/backend-defaults": "0.2.10",
|
||||
"@backstage/backend-dev-utils": "0.1.4",
|
||||
"@backstage/backend-dynamic-feature-service": "0.2.0",
|
||||
"example-backend-next": "0.0.20",
|
||||
"@backstage/backend-openapi-utils": "0.1.3",
|
||||
"@backstage/backend-plugin-api": "0.6.10",
|
||||
"@backstage/backend-tasks": "0.5.15",
|
||||
"@backstage/backend-test-utils": "0.3.0",
|
||||
"@backstage/catalog-client": "1.6.0",
|
||||
"@backstage/catalog-model": "1.4.4",
|
||||
"@backstage/cli": "0.25.2",
|
||||
"@backstage/cli-common": "0.1.13",
|
||||
"@backstage/cli-node": "0.2.3",
|
||||
"@backstage/codemods": "0.1.47",
|
||||
"@backstage/config": "1.1.1",
|
||||
"@backstage/config-loader": "1.6.2",
|
||||
"@backstage/core-app-api": "1.12.0",
|
||||
"@backstage/core-compat-api": "0.2.0",
|
||||
"@backstage/core-components": "0.14.0",
|
||||
"@backstage/core-plugin-api": "1.9.0",
|
||||
"@backstage/create-app": "0.5.11",
|
||||
"@backstage/dev-utils": "1.0.27",
|
||||
"e2e-test": "0.2.12",
|
||||
"@backstage/e2e-test-utils": "0.1.1",
|
||||
"@backstage/errors": "1.2.3",
|
||||
"@backstage/eslint-plugin": "0.1.5",
|
||||
"@backstage/frontend-app-api": "0.6.0",
|
||||
"@backstage/frontend-plugin-api": "0.6.0",
|
||||
"@backstage/frontend-test-utils": "0.1.2",
|
||||
"@backstage/integration": "1.9.0",
|
||||
"@backstage/integration-aws-node": "0.1.9",
|
||||
"@backstage/integration-react": "1.1.24",
|
||||
"@backstage/release-manifests": "0.0.11",
|
||||
"@backstage/repo-tools": "0.6.0",
|
||||
"@techdocs/cli": "1.8.2",
|
||||
"techdocs-cli-embedded-app": "0.2.91",
|
||||
"@backstage/test-utils": "1.5.0",
|
||||
"@backstage/theme": "0.5.1",
|
||||
"@backstage/types": "1.1.1",
|
||||
"@backstage/version-bridge": "1.0.7",
|
||||
"@backstage/plugin-adr": "0.6.13",
|
||||
"@backstage/plugin-adr-backend": "0.4.7",
|
||||
"@backstage/plugin-adr-common": "0.2.20",
|
||||
"@backstage/plugin-airbrake": "0.3.30",
|
||||
"@backstage/plugin-airbrake-backend": "0.3.7",
|
||||
"@backstage/plugin-allure": "0.1.46",
|
||||
"@backstage/plugin-analytics-module-ga": "0.2.0",
|
||||
"@backstage/plugin-analytics-module-ga4": "0.2.0",
|
||||
"@backstage/plugin-analytics-module-newrelic-browser": "0.1.0",
|
||||
"@backstage/plugin-apache-airflow": "0.2.20",
|
||||
"@backstage/plugin-api-docs": "0.11.0",
|
||||
"@backstage/plugin-api-docs-module-protoc-gen-doc": "0.1.6",
|
||||
"@backstage/plugin-apollo-explorer": "0.1.20",
|
||||
"@backstage/plugin-app-backend": "0.3.58",
|
||||
"@backstage/plugin-app-node": "0.1.10",
|
||||
"@backstage/plugin-app-visualizer": "0.1.1",
|
||||
"@backstage/plugin-auth-backend": "0.21.0",
|
||||
"@backstage/plugin-auth-backend-module-atlassian-provider": "0.1.2",
|
||||
"@backstage/plugin-auth-backend-module-aws-alb-provider": "0.1.0",
|
||||
"@backstage/plugin-auth-backend-module-gcp-iap-provider": "0.2.4",
|
||||
"@backstage/plugin-auth-backend-module-github-provider": "0.1.7",
|
||||
"@backstage/plugin-auth-backend-module-gitlab-provider": "0.1.7",
|
||||
"@backstage/plugin-auth-backend-module-google-provider": "0.1.7",
|
||||
"@backstage/plugin-auth-backend-module-microsoft-provider": "0.1.5",
|
||||
"@backstage/plugin-auth-backend-module-oauth2-provider": "0.1.7",
|
||||
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "0.1.2",
|
||||
"@backstage/plugin-auth-backend-module-oidc-provider": "0.1.0",
|
||||
"@backstage/plugin-auth-backend-module-okta-provider": "0.0.3",
|
||||
"@backstage/plugin-auth-backend-module-pinniped-provider": "0.1.4",
|
||||
"@backstage/plugin-auth-backend-module-vmware-cloud-provider": "0.1.2",
|
||||
"@backstage/plugin-auth-node": "0.4.4",
|
||||
"@backstage/plugin-azure-devops": "0.3.12",
|
||||
"@backstage/plugin-azure-devops-backend": "0.5.2",
|
||||
"@backstage/plugin-azure-devops-common": "0.3.2",
|
||||
"@backstage/plugin-azure-sites": "0.1.19",
|
||||
"@backstage/plugin-azure-sites-backend": "0.2.0",
|
||||
"@backstage/plugin-azure-sites-common": "0.1.2",
|
||||
"@backstage/plugin-badges": "0.2.54",
|
||||
"@backstage/plugin-badges-backend": "0.3.7",
|
||||
"@backstage/plugin-bazaar": "0.2.22",
|
||||
"@backstage/plugin-bazaar-backend": "0.3.8",
|
||||
"@backstage/plugin-bitbucket-cloud-common": "0.2.16",
|
||||
"@backstage/plugin-bitrise": "0.1.57",
|
||||
"@backstage/plugin-catalog": "1.17.0",
|
||||
"@backstage/plugin-catalog-backend": "1.17.0",
|
||||
"@backstage/plugin-catalog-backend-module-aws": "0.3.4",
|
||||
"@backstage/plugin-catalog-backend-module-azure": "0.1.29",
|
||||
"@backstage/plugin-catalog-backend-module-backstage-openapi": "0.1.3",
|
||||
"@backstage/plugin-catalog-backend-module-bitbucket-cloud": "0.1.25",
|
||||
"@backstage/plugin-catalog-backend-module-bitbucket-server": "0.1.23",
|
||||
"@backstage/plugin-catalog-backend-module-gcp": "0.1.10",
|
||||
"@backstage/plugin-catalog-backend-module-gerrit": "0.1.26",
|
||||
"@backstage/plugin-catalog-backend-module-github": "0.5.0",
|
||||
"@backstage/plugin-catalog-backend-module-github-org": "0.1.4",
|
||||
"@backstage/plugin-catalog-backend-module-gitlab": "0.3.7",
|
||||
"@backstage/plugin-catalog-backend-module-incremental-ingestion": "0.4.14",
|
||||
"@backstage/plugin-catalog-backend-module-ldap": "0.5.25",
|
||||
"@backstage/plugin-catalog-backend-module-msgraph": "0.5.17",
|
||||
"@backstage/plugin-catalog-backend-module-openapi": "0.1.27",
|
||||
"@backstage/plugin-catalog-backend-module-puppetdb": "0.1.15",
|
||||
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "0.1.7",
|
||||
"@backstage/plugin-catalog-backend-module-unprocessed": "0.3.7",
|
||||
"@backstage/plugin-catalog-common": "1.0.21",
|
||||
"@backstage/plugin-catalog-graph": "0.4.0",
|
||||
"@backstage/plugin-catalog-import": "0.10.6",
|
||||
"@backstage/plugin-catalog-node": "1.7.0",
|
||||
"@backstage/plugin-catalog-react": "1.10.0",
|
||||
"@backstage/plugin-catalog-unprocessed-entities": "0.1.8",
|
||||
"@backstage/plugin-cicd-statistics": "0.1.32",
|
||||
"@backstage/plugin-cicd-statistics-module-gitlab": "0.1.26",
|
||||
"@backstage/plugin-circleci": "0.3.30",
|
||||
"@backstage/plugin-cloudbuild": "0.4.0",
|
||||
"@backstage/plugin-code-climate": "0.1.30",
|
||||
"@backstage/plugin-code-coverage": "0.2.23",
|
||||
"@backstage/plugin-code-coverage-backend": "0.2.24",
|
||||
"@backstage/plugin-codescene": "0.1.22",
|
||||
"@backstage/plugin-config-schema": "0.1.50",
|
||||
"@backstage/plugin-cost-insights": "0.12.19",
|
||||
"@backstage/plugin-cost-insights-common": "0.1.2",
|
||||
"@backstage/plugin-devtools": "0.1.9",
|
||||
"@backstage/plugin-devtools-backend": "0.2.7",
|
||||
"@backstage/plugin-devtools-common": "0.1.8",
|
||||
"@backstage/plugin-dynatrace": "9.0.0",
|
||||
"@backstage/plugin-entity-feedback": "0.2.13",
|
||||
"@backstage/plugin-entity-feedback-backend": "0.2.7",
|
||||
"@backstage/plugin-entity-feedback-common": "0.1.3",
|
||||
"@backstage/plugin-entity-validation": "0.1.15",
|
||||
"@backstage/plugin-events-backend": "0.2.19",
|
||||
"@backstage/plugin-events-backend-module-aws-sqs": "0.2.13",
|
||||
"@backstage/plugin-events-backend-module-azure": "0.1.20",
|
||||
"@backstage/plugin-events-backend-module-bitbucket-cloud": "0.1.20",
|
||||
"@backstage/plugin-events-backend-module-gerrit": "0.1.20",
|
||||
"@backstage/plugin-events-backend-module-github": "0.1.20",
|
||||
"@backstage/plugin-events-backend-module-gitlab": "0.1.20",
|
||||
"@backstage/plugin-events-backend-test-utils": "0.1.20",
|
||||
"@backstage/plugin-events-node": "0.2.19",
|
||||
"@internal/plugin-todo-list": "1.0.22",
|
||||
"@internal/plugin-todo-list-backend": "1.0.22",
|
||||
"@internal/plugin-todo-list-common": "1.0.17",
|
||||
"@backstage/plugin-explore": "0.4.16",
|
||||
"@backstage/plugin-explore-backend": "0.0.20",
|
||||
"@backstage/plugin-explore-common": "0.0.2",
|
||||
"@backstage/plugin-explore-react": "0.0.36",
|
||||
"@backstage/plugin-firehydrant": "0.2.14",
|
||||
"@backstage/plugin-fossa": "0.2.62",
|
||||
"@backstage/plugin-gcalendar": "0.3.23",
|
||||
"@backstage/plugin-gcp-projects": "0.3.46",
|
||||
"@backstage/plugin-git-release-manager": "0.3.42",
|
||||
"@backstage/plugin-github-actions": "0.6.11",
|
||||
"@backstage/plugin-github-deployments": "0.1.61",
|
||||
"@backstage/plugin-github-issues": "0.2.19",
|
||||
"@backstage/plugin-github-pull-requests-board": "0.1.24",
|
||||
"@backstage/plugin-gitops-profiles": "0.3.45",
|
||||
"@backstage/plugin-gocd": "0.1.36",
|
||||
"@backstage/plugin-graphiql": "0.3.3",
|
||||
"@backstage/plugin-graphql-voyager": "0.1.12",
|
||||
"@backstage/plugin-home": "0.6.2",
|
||||
"@backstage/plugin-home-react": "0.1.8",
|
||||
"@backstage/plugin-ilert": "0.2.19",
|
||||
"@backstage/plugin-jenkins": "0.9.5",
|
||||
"@backstage/plugin-jenkins-backend": "0.3.4",
|
||||
"@backstage/plugin-jenkins-common": "0.1.24",
|
||||
"@backstage/plugin-kafka": "0.3.30",
|
||||
"@backstage/plugin-kafka-backend": "0.3.8",
|
||||
"@backstage/plugin-kubernetes": "0.11.5",
|
||||
"@backstage/plugin-kubernetes-backend": "0.15.0",
|
||||
"@backstage/plugin-kubernetes-cluster": "0.0.6",
|
||||
"@backstage/plugin-kubernetes-common": "0.7.4",
|
||||
"@backstage/plugin-kubernetes-node": "0.1.4",
|
||||
"@backstage/plugin-kubernetes-react": "0.3.0",
|
||||
"@backstage/plugin-lighthouse": "0.4.15",
|
||||
"@backstage/plugin-lighthouse-backend": "0.4.2",
|
||||
"@backstage/plugin-lighthouse-common": "0.1.4",
|
||||
"@backstage/plugin-linguist": "0.1.15",
|
||||
"@backstage/plugin-linguist-backend": "0.5.7",
|
||||
"@backstage/plugin-linguist-common": "0.1.2",
|
||||
"@backstage/plugin-microsoft-calendar": "0.1.12",
|
||||
"@backstage/plugin-newrelic": "0.3.45",
|
||||
"@backstage/plugin-newrelic-dashboard": "0.3.5",
|
||||
"@backstage/plugin-nomad": "0.1.11",
|
||||
"@backstage/plugin-nomad-backend": "0.1.12",
|
||||
"@backstage/plugin-notifications": "0.0.1",
|
||||
"@backstage/plugin-notifications-backend": "0.0.1",
|
||||
"@backstage/plugin-notifications-common": "0.0.1",
|
||||
"@backstage/plugin-notifications-node": "0.0.1",
|
||||
"@backstage/plugin-octopus-deploy": "0.2.12",
|
||||
"@backstage/plugin-opencost": "0.2.5",
|
||||
"@backstage/plugin-org": "0.6.20",
|
||||
"@backstage/plugin-org-react": "0.1.19",
|
||||
"@backstage/plugin-pagerduty": "0.7.2",
|
||||
"@backstage/plugin-periskop": "0.1.28",
|
||||
"@backstage/plugin-periskop-backend": "0.2.8",
|
||||
"@backstage/plugin-permission-backend": "0.5.33",
|
||||
"@backstage/plugin-permission-backend-module-allow-all-policy": "0.1.7",
|
||||
"@backstage/plugin-permission-common": "0.7.12",
|
||||
"@backstage/plugin-permission-node": "0.7.21",
|
||||
"@backstage/plugin-permission-react": "0.4.20",
|
||||
"@backstage/plugin-playlist": "0.2.4",
|
||||
"@backstage/plugin-playlist-backend": "0.3.14",
|
||||
"@backstage/plugin-playlist-common": "0.1.14",
|
||||
"@backstage/plugin-proxy-backend": "0.4.8",
|
||||
"@backstage/plugin-puppetdb": "0.1.13",
|
||||
"@backstage/plugin-rollbar": "0.4.30",
|
||||
"@backstage/plugin-rollbar-backend": "0.1.55",
|
||||
"@backstage/plugin-scaffolder": "1.18.0",
|
||||
"@backstage/plugin-scaffolder-backend": "1.21.0",
|
||||
"@backstage/plugin-scaffolder-backend-module-azure": "0.1.2",
|
||||
"@backstage/plugin-scaffolder-backend-module-bitbucket": "0.2.0",
|
||||
"@backstage/plugin-scaffolder-backend-module-bitbucket-cloud": "0.1.0",
|
||||
"@backstage/plugin-scaffolder-backend-module-bitbucket-server": "0.1.0",
|
||||
"@backstage/plugin-scaffolder-backend-module-confluence-to-markdown": "0.2.11",
|
||||
"@backstage/plugin-scaffolder-backend-module-cookiecutter": "0.2.34",
|
||||
"@backstage/plugin-scaffolder-backend-module-gerrit": "0.1.2",
|
||||
"@backstage/plugin-scaffolder-backend-module-gitea": "0.1.0",
|
||||
"@backstage/plugin-scaffolder-backend-module-github": "0.2.0",
|
||||
"@backstage/plugin-scaffolder-backend-module-gitlab": "0.2.13",
|
||||
"@backstage/plugin-scaffolder-backend-module-rails": "0.4.27",
|
||||
"@backstage/plugin-scaffolder-backend-module-sentry": "0.1.18",
|
||||
"@backstage/plugin-scaffolder-backend-module-yeoman": "0.2.31",
|
||||
"@backstage/plugin-scaffolder-common": "1.5.0",
|
||||
"@backstage/plugin-scaffolder-node": "0.3.0",
|
||||
"@backstage/plugin-scaffolder-react": "1.8.0",
|
||||
"@backstage/plugin-search": "1.4.6",
|
||||
"@backstage/plugin-search-backend": "1.5.0",
|
||||
"@backstage/plugin-search-backend-module-catalog": "0.1.14",
|
||||
"@backstage/plugin-search-backend-module-elasticsearch": "1.3.13",
|
||||
"@backstage/plugin-search-backend-module-explore": "0.1.14",
|
||||
"@backstage/plugin-search-backend-module-pg": "0.5.19",
|
||||
"@backstage/plugin-search-backend-module-stack-overflow-collator": "0.1.3",
|
||||
"@backstage/plugin-search-backend-module-techdocs": "0.1.14",
|
||||
"@backstage/plugin-search-backend-node": "1.2.14",
|
||||
"@backstage/plugin-search-common": "1.2.10",
|
||||
"@backstage/plugin-search-react": "1.7.6",
|
||||
"@backstage/plugin-sentry": "0.5.15",
|
||||
"@backstage/plugin-shortcuts": "0.3.19",
|
||||
"@backstage/plugin-signals": "0.0.1",
|
||||
"@backstage/plugin-signals-backend": "0.0.1",
|
||||
"@backstage/plugin-signals-node": "0.0.1",
|
||||
"@backstage/plugin-signals-react": "0.0.1",
|
||||
"@backstage/plugin-sonarqube": "0.7.12",
|
||||
"@backstage/plugin-sonarqube-backend": "0.2.12",
|
||||
"@backstage/plugin-sonarqube-react": "0.1.13",
|
||||
"@backstage/plugin-splunk-on-call": "0.4.19",
|
||||
"@backstage/plugin-stack-overflow": "0.1.25",
|
||||
"@backstage/plugin-stack-overflow-backend": "0.2.14",
|
||||
"@backstage/plugin-stackstorm": "0.1.11",
|
||||
"@backstage/plugin-tech-insights": "0.3.22",
|
||||
"@backstage/plugin-tech-insights-backend": "0.5.24",
|
||||
"@backstage/plugin-tech-insights-backend-module-jsonfc": "0.1.42",
|
||||
"@backstage/plugin-tech-insights-common": "0.2.12",
|
||||
"@backstage/plugin-tech-insights-node": "0.4.16",
|
||||
"@backstage/plugin-tech-radar": "0.6.13",
|
||||
"@backstage/plugin-techdocs": "1.10.0",
|
||||
"@backstage/plugin-techdocs-addons-test-utils": "1.0.27",
|
||||
"@backstage/plugin-techdocs-backend": "1.9.3",
|
||||
"@backstage/plugin-techdocs-module-addons-contrib": "1.1.5",
|
||||
"@backstage/plugin-techdocs-node": "1.11.2",
|
||||
"@backstage/plugin-techdocs-react": "1.1.16",
|
||||
"@backstage/plugin-todo": "0.2.34",
|
||||
"@backstage/plugin-todo-backend": "0.3.8",
|
||||
"@backstage/plugin-user-settings": "0.8.1",
|
||||
"@backstage/plugin-user-settings-backend": "0.2.9",
|
||||
"@backstage/plugin-vault": "0.1.25",
|
||||
"@backstage/plugin-vault-backend": "0.4.3",
|
||||
"@backstage/plugin-vault-node": "0.1.3",
|
||||
"@backstage/plugin-xcmetrics": "0.2.48"
|
||||
},
|
||||
"changesets": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
---
|
||||
|
||||
Added 'root' and 'label' class key to TemplateCategoryPicker
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Migrated to support new auth services. The `CatalogBuilder.create` method now accepts a `discovery` option, which is recommended to forward from the plugin environment, as it will otherwise fall back to use the `HostDiscovery` implementation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `KubernetesBuilder.createBuilder` method now requires the `discovery` service to be forwarded from the plugin environment. This is part of the migration to support new auth services.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-api-docs': patch
|
||||
---
|
||||
|
||||
Use the `AppIcon` component in the navigation item extension.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Updated dependency `testcontainers` to `^10.0.0`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-adr-backend': patch
|
||||
---
|
||||
|
||||
Updated dependency `marked` to `^12.0.0`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
'@backstage/plugin-proxy-backend': patch
|
||||
---
|
||||
|
||||
Updated dependency `yup` to `^1.0.0`.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
'@backstage/backend-tasks': patch
|
||||
'@backstage/backend-test-utils': patch
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
'@backstage/plugin-auth-node': patch
|
||||
'@backstage/plugin-catalog-backend-module-aws': patch
|
||||
'@backstage/plugin-catalog-backend-module-azure': patch
|
||||
'@backstage/plugin-catalog-backend-module-bitbucket-cloud': patch
|
||||
'@backstage/plugin-catalog-backend-module-bitbucket-server': patch
|
||||
'@backstage/plugin-catalog-backend-module-gerrit': patch
|
||||
'@backstage/plugin-catalog-backend-module-github': patch
|
||||
'@backstage/plugin-catalog-backend-module-gitlab': patch
|
||||
'@backstage/plugin-catalog-backend-module-incremental-ingestion': patch
|
||||
'@backstage/plugin-catalog-backend-module-ldap': patch
|
||||
'@backstage/plugin-catalog-backend-module-msgraph': patch
|
||||
'@backstage/plugin-catalog-backend-module-puppetdb': patch
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
'@backstage/plugin-code-coverage-backend': patch
|
||||
'@backstage/plugin-linguist-backend': patch
|
||||
'@backstage/plugin-notifications-backend': patch
|
||||
'@backstage/plugin-notifications-node': patch
|
||||
'@backstage/plugin-permission-common': patch
|
||||
'@backstage/plugin-playlist-backend': patch
|
||||
'@backstage/plugin-proxy-backend': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
'@backstage/plugin-search-backend-module-elasticsearch': patch
|
||||
'@backstage/plugin-search-backend-module-pg': patch
|
||||
'@backstage/plugin-search-backend-node': patch
|
||||
'@backstage/plugin-shortcuts': patch
|
||||
'@backstage/plugin-signals-backend': patch
|
||||
'@backstage/plugin-signals-node': patch
|
||||
'@backstage/plugin-signals': patch
|
||||
'@backstage/plugin-tech-insights-backend': patch
|
||||
---
|
||||
|
||||
Updated dependency `uuid` to `^9.0.0`.
|
||||
Updated dependency `@types/uuid` to `^9.0.0`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Updated dependency `yauzl` to `^3.0.0`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
`SignInPage`'s `'guest'` provider now supports the `@backstage/plugin-auth-backend-module-guest-provider` package to generate tokens. It will continue to use the old frontend-only auth as a fallback.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-bitbucket-cloud': minor
|
||||
---
|
||||
|
||||
BREAKING CHANGE: Migrates the `BitbucketCloudEntityProvider` to use the `EventsService`; fix new backend system support.
|
||||
|
||||
`BitbucketCloudEntityProvider.fromConfig` accepts `events: EventsService` as optional argument to its `options`.
|
||||
With provided `events`, the event-based updates/refresh will be available.
|
||||
However, the `EventSubscriber` interface was removed including its `supportsEventTopics()` and `onEvent(params)`.
|
||||
|
||||
The event subscription happens on `connect(connection)` if the `events` is available.
|
||||
|
||||
**Migration:**
|
||||
|
||||
```diff
|
||||
const bitbucketCloudProvider = BitbucketCloudEntityProvider.fromConfig(
|
||||
env.config,
|
||||
{
|
||||
catalogApi: new CatalogClient({ discoveryApi: env.discovery }),
|
||||
+ events: env.events,
|
||||
logger: env.logger,
|
||||
scheduler: env.scheduler,
|
||||
tokenManager: env.tokenManager,
|
||||
},
|
||||
);
|
||||
- env.eventBroker.subscribe(bitbucketCloudProvider);
|
||||
```
|
||||
|
||||
**New Backend System:**
|
||||
|
||||
Before this change, using this module with the new backend system was broken.
|
||||
Now, you can add the catalog module for Bitbucket Cloud incl. event support backend.
|
||||
Event support will always be enabled.
|
||||
However, no updates/refresh will happen without receiving events.
|
||||
|
||||
```ts
|
||||
backend.add(
|
||||
import('@backstage/plugin-catalog-backend-module-bitbucket-cloud/alpha'),
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-import': patch
|
||||
---
|
||||
|
||||
Fixed an issue generating a wrong entity link at the end of the import process
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-dynamic-feature-service': patch
|
||||
---
|
||||
|
||||
Add `events: EventsService` to `LegacyPluginEnvironment`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': minor
|
||||
---
|
||||
|
||||
Add support for Service Tokens to Cloudflare Access auth provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Made the `DefaultUserInfoService` claims check stricter
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-notifications-backend': minor
|
||||
'@backstage/plugin-notifications': minor
|
||||
'@backstage/plugin-notifications-common': patch
|
||||
---
|
||||
|
||||
The Notifications frontend has been redesigned towards list view with condensed row details. The 'done' attribute has been removed to keep the Notifications aligned with the idea of a messaging system instead of a task manager.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-permission-backend': patch
|
||||
---
|
||||
|
||||
Migrated to use the new auth services introduced in [BEP-0003](https://github.com/backstage/backstage/blob/master/beps/0003-auth-architecture-evolution/README.md).
|
||||
|
||||
The `createRouter` function now accepts `auth`, `httpAuth` and `userInfo` options. Theses are used internally to support the new backend system, and can be ignored.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Allow the `spec.target` field to be searchable in the catalog table for locations. Previously, only the `spec.targets` field was be searchable. This makes locations generated by providers such as the `GithubEntityProvider` searchable in the catalog table. [#23098](https://github.com/backstage/backstage/issues/23098)
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
'@backstage/plugin-events-backend': minor
|
||||
---
|
||||
|
||||
BREAKING CHANGE: Migrate `HttpPostIngressEventPublisher` and `eventsPlugin` to use `EventsService`.
|
||||
|
||||
Uses the `EventsService` instead of `EventBroker` at `HttpPostIngressEventPublisher`,
|
||||
dropping the use of `EventPublisher` including `setEventBroker(..)`.
|
||||
|
||||
Now, `HttpPostIngressEventPublisher.fromConfig` requires `events: EventsService` as option.
|
||||
|
||||
```diff
|
||||
const http = HttpPostIngressEventPublisher.fromConfig({
|
||||
config: env.config,
|
||||
+ events: env.events,
|
||||
logger: env.logger,
|
||||
});
|
||||
http.bind(eventsRouter);
|
||||
|
||||
// e.g. at packages/backend/src/plugins/events.ts
|
||||
- await new EventsBackend(env.logger)
|
||||
- .setEventBroker(env.eventBroker)
|
||||
- .addPublishers(http)
|
||||
- .start();
|
||||
|
||||
// or for other kinds of setups
|
||||
- await Promise.all(http.map(publisher => publisher.setEventBroker(eventBroker)));
|
||||
```
|
||||
|
||||
`eventsPlugin` uses the `eventsServiceRef` as dependency.
|
||||
Unsupported (and deprecated) extension point methods will throw an error to prevent unintended behavior.
|
||||
|
||||
```ts
|
||||
import { eventsServiceRef } from '@backstage/plugin-events-node';
|
||||
```
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- run: yarn build-storybook
|
||||
|
||||
- uses: chromaui/action@c9067691aca4a28d6fbb40d9eea6e144369fbcae # v10
|
||||
- uses: chromaui/action@fd0e276c344bab4dc69a023fdf89ffb9b79b3b31 # v11
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# projectToken intentionally shared to allow collaborators to run Chromatic on forks
|
||||
|
||||
@@ -73,17 +73,14 @@ Team: @backstage/permission-maintainers
|
||||
|
||||
Scope: The Permission Framework and plugins integrating with the permission framework
|
||||
|
||||
| Name | Organization | Team | GitHub | Discord |
|
||||
| -------------------- | ------------ | --------------- | ----------------------------------------------- | ---------------- |
|
||||
| Ainhoa Larumbe | Spotify | Imaginary Goats | [ainhoaL](http://github.com/ainhoaL) | ainhoa#8085 |
|
||||
| Claire Casey | Spotify | Imaginary Goats | [clairelcasey](http://github.com/clairelcasey) | clairecasey#2710 |
|
||||
| Eric Peterson | Spotify | Imaginary Goats | [iamEAP](http://github.com/iamEAP) | iamEAP#3058 |
|
||||
| Harry Hogg | Spotify | Imaginary Goats | [HHogg](http://github.com/HHogg) | simplex#3451 |
|
||||
| Joon Park | Spotify | Imaginary Goats | [Joonpark13](http://github.com/Joonpark13) | Sixpool#5060 |
|
||||
| Lynette Lopez | Spotify | Imaginary Goats | [lynettelopez](https://github.com/lynettelopez) | lynettelopez |
|
||||
| Mike Lewis | Spotify | Imaginary Goats | [mtlewis](http://github.com/mtlewis) | mtlewis#3658 |
|
||||
| Tim Hansen | Spotify | Imaginary Goats | [timbonicus](http://github.com/timbonicus) | timbonicus#6871 |
|
||||
| Vincenzo Scamporlino | Spotify | Imaginary Goats | [vinzscam](http://github.com/vinzscam) | vinzscam#6944 |
|
||||
| Name | Organization | Team | GitHub | Discord |
|
||||
| -------------------- | ------------ | --------------- | ------------------------------------------ | ------------- |
|
||||
| Ainhoa Larumbe | Spotify | Imaginary Goats | [ainhoaL](http://github.com/ainhoaL) | ainhoa#8085 |
|
||||
| Eric Peterson | Spotify | Imaginary Goats | [iamEAP](http://github.com/iamEAP) | iamEAP#3058 |
|
||||
| Harry Hogg | Spotify | Imaginary Goats | [HHogg](http://github.com/HHogg) | simplex#3451 |
|
||||
| Joon Park | Spotify | Imaginary Goats | [Joonpark13](http://github.com/Joonpark13) | Sixpool#5060 |
|
||||
| Mike Lewis | Spotify | Imaginary Goats | [mtlewis](http://github.com/mtlewis) | mtlewis#3658 |
|
||||
| Vincenzo Scamporlino | Spotify | Imaginary Goats | [vinzscam](http://github.com/vinzscam) | vinzscam#6944 |
|
||||
|
||||
### TechDocs
|
||||
|
||||
@@ -127,9 +124,9 @@ Team: @backstage/openapi-tooling-maintainers
|
||||
|
||||
Scope: Tooling for frontend and backend schema-first OpenAPI development.
|
||||
|
||||
| Name | Organization | GitHub | Discord |
|
||||
| -------------- | ------------ | --------------------------------------- | ------------- |
|
||||
| Aramis Sennyey | | [sennyeya](https://github.com/sennyeya) | `Aramis#7984` |
|
||||
| Name | Organization | GitHub | Discord |
|
||||
| -------------- | ------------ | ----------------------------------------------------- | ------------- |
|
||||
| Aramis Sennyey | | [aramissennyeydd](https://github.com/aramissennyeydd) | `Aramis#7984` |
|
||||
|
||||
### Scaffolder
|
||||
|
||||
@@ -157,7 +154,7 @@ Scope: The Scaffolder frontend and backend plugins, and related tooling.
|
||||
| Alex Crome | | [afscrome](https://github.com/afscrome) | `afscrome` |
|
||||
| Andre Wanlin | Spotify | [awanlin](https://github.com/awanlin) | `ahhhndre` |
|
||||
| Andrew Thauer | Wealthsimple | [andrewthauer](https://github.com/andrewthauer) | `andrewthauer#3060` |
|
||||
| Aramis Sennyey | | [sennyeya](https://github.com/sennyeya) | `Aramis#7984` |
|
||||
| Aramis Sennyey | | [aramissennyeydd](https://github.com/aramissennyeydd) | `Aramis#7984` |
|
||||
| Brian Fletcher | Roadie.io | [punkle](https://github.com/punkle) | `Brian Fletcher#7051` |
|
||||
| Carlos Esteban Lopez Jaramillo | VMWare | [luchillo17](https://github.com/luchillo17) | `luchillo17#8777` |
|
||||
| David Tuite | Roadie.io | [dtuite](https://github.com/dtuite) | `David Tuite (roadie.io)#1010` |
|
||||
|
||||
+4
-1
@@ -242,7 +242,7 @@ catalog:
|
||||
- Domain
|
||||
- Location
|
||||
providers:
|
||||
openapi:
|
||||
backstageOpenapi:
|
||||
plugins:
|
||||
- catalog
|
||||
- search
|
||||
@@ -399,6 +399,9 @@ auth:
|
||||
scopes: ${AUTH_ATLASSIAN_SCOPES}
|
||||
myproxy:
|
||||
development: {}
|
||||
guest:
|
||||
development: {}
|
||||
|
||||
costInsights:
|
||||
engineerCost: 200000
|
||||
engineerThreshold: 0.5
|
||||
|
||||
@@ -68,9 +68,9 @@ Two new backend service interfaces are introduced to support these new features.
|
||||
|
||||
The proposed design leaves the decision for how different endpoints are protected to the implementation of the plugin backends themselves. This includes whether particular routes should allow anonymous access, access from users authenticated via a cookie, or perhaps only allow access from other plugin backends and external services. This means that integrators do not need to - and do not have the ability to - configure access controls of individual endpoints, except for what the permission system already provides, and what is made available through static configuration or extension points.
|
||||
|
||||
In order to allow for cookie-based authentication of incoming user requests, the `auth` plugin backend is extended to be able to issue user tokens with reduced scope, which in turn integrate with the new `AuthService` and `HttpAuthService`. The ability to use cookie auth for requests is an opt-in per route and is only be permitted for read methods (`GET`, `HEAD`, `OPTIONS`). The actual implementation of cookie-based flows will be up to each plugin, but with significant help from the new auth service interfaces.
|
||||
To ensure a secure-by-default design, there is a default access control policy that applies to all plugin routes, known as the "default auth policy". This policy is to only allow access from authenticated users and services, and is implemented in the `HttpRouterService` interface. In order to allow either unauthenticated access or cookie-based access, a plugin must opt-out of the default auth policy for specific path prefixes, effectively leaving the access control implementation to the plugin itself. This is done through the new `addAuthPolicy` method that is added to the `HttpRouterService` interface.
|
||||
|
||||
In order to allow either unauthenticated access or cookie-based access, a plugin must explicitly opt-in the specific path prefixes that these should be available at. This is done through a new method that is added to the `HttpRouterService` interface.
|
||||
In order to allow for cookie-based authentication of incoming user requests, the `AuthService` is able to issue user tokens with limited scope. These limited scope tokens can still be used to fetch user information and in on-behalf-of service calls, but they are rejected by the default auth policy. The `HttpAuthService` provides a standardized way of handling cookies, which integrates with the `'user-cookie'` auth policy. The limited tokens can also be used in other contexts where it is beneficial to avoid storing full user credentials, but instead use credentials that can be upgraded in a controlled manner, such as scaffolder tasks. The `AuthService` implementation can choose to have a longer expiry of the limited tokens compared to the full user tokens, but this is not a requirement.
|
||||
|
||||
For service-to-service communication we will move away from reusing user tokens in upstream requests. We will instead implement an "On-Behalf-Of" flow where incoming user credentials are encapsulated in a service token for the upstream request. In line with this the new auth service interfaces will aim to make it difficult to directly forward credentials from incoming requests, and instead encourage that plugin backends issue new service credentials for upstream requests.
|
||||
|
||||
@@ -98,7 +98,7 @@ export type BackstageUserPrincipal = {
|
||||
};
|
||||
|
||||
export type BackstageServicePrincipal = {
|
||||
type: 'user';
|
||||
type: 'service';
|
||||
|
||||
// Exact format TBD, possibly 'plugin:<pluginId>' or 'external:<externalServiceId>'
|
||||
subject: string;
|
||||
@@ -110,6 +110,8 @@ export type BackstageServicePrincipal = {
|
||||
export type BackstageCredentials<TPrincipal = unknown> = {
|
||||
$$type: '@backstage/BackstageCredentials';
|
||||
|
||||
expiresAt?: Date;
|
||||
|
||||
principal: TPrincipal;
|
||||
};
|
||||
|
||||
@@ -120,13 +122,20 @@ export type BackstagePrincipalTypes = {
|
||||
};
|
||||
|
||||
export interface AuthService {
|
||||
authenticate(token: string): Promise<BackstageCredentials>;
|
||||
authenticate(
|
||||
token: string,
|
||||
options?: {
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials>;
|
||||
|
||||
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
|
||||
credentials: BackstageCredentials,
|
||||
type: TType,
|
||||
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
|
||||
|
||||
getNoneCredentials(): Promise<BackstageCredentials<BackstageUserPrincipal>>;
|
||||
|
||||
getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
>;
|
||||
@@ -135,6 +144,10 @@ export interface AuthService {
|
||||
onBehalfOf: BackstageCredentials;
|
||||
targetPluginId: string;
|
||||
}): Promise<{ token: string }>;
|
||||
|
||||
getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,6 +162,7 @@ export interface BackstageUserInfo {
|
||||
}
|
||||
|
||||
export interface UserInfoService {
|
||||
// The implementation of this method should support both regular and limited user credentials
|
||||
getUserInfo(credentials: BackstageCredentials): Promise<BackstageUserInfo>;
|
||||
}
|
||||
```
|
||||
@@ -159,7 +173,7 @@ The `UserInfoService` is exported by `@backstage/auth-node`, and the initial imp
|
||||
|
||||
> Open question: Should this instead be added to the `HttpAuthService`? It may fit a bit better there, but on the other hand it might make sense to add additional policies unrelated to authentication too, such as rate limiting.
|
||||
|
||||
The `HttpRouterService` interface will be extended with the ability to opt-out of the default protection of endpoints, enabling either cookie auth or unauthenticated access.
|
||||
The `HttpRouterService` interface will be extended with the ability to opt-out of the default protection of endpoints, enabling cookie or unauthenticated access.
|
||||
|
||||
```ts
|
||||
export interface HttpRouterServiceAuthPolicy {
|
||||
@@ -201,7 +215,7 @@ export default createBackendPlugin({
|
||||
|
||||
This is expected to be the pattern for the vast majority of plugins.
|
||||
|
||||
#### A plugin with an endpoint that only allows cookie auth
|
||||
#### A plugin with a cookie-based authentication endpoint
|
||||
|
||||
```ts
|
||||
export default createBackendPlugin({
|
||||
@@ -209,11 +223,25 @@ export default createBackendPlugin({
|
||||
register(env) {
|
||||
env.registerInit({
|
||||
deps: {
|
||||
auth: coreServices.auth,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
http: coreServices.httpRouter,
|
||||
},
|
||||
async init({ http }) {
|
||||
async init({ auth, httpAuth, http }) {
|
||||
const router = Router();
|
||||
|
||||
// Endpoint that sets the cookie for the user
|
||||
router.get('/cookie', async (req, res) => {
|
||||
const { expiresAt } = await httpAuth.issueUserCookie(res);
|
||||
|
||||
res.json({ expiresAt: expiresAt.toISOString() });
|
||||
});
|
||||
|
||||
// Endpoint protected by cookie auth
|
||||
router.get('/static', express.static(/* ... */));
|
||||
|
||||
// The order of these two calls does not matter
|
||||
http.use(await createRouter(/* ... */));
|
||||
http.use(router);
|
||||
http.addAuthPolicy({
|
||||
path: '/static',
|
||||
allow: 'user-cookie',
|
||||
@@ -224,7 +252,7 @@ export default createBackendPlugin({
|
||||
});
|
||||
```
|
||||
|
||||
#### A plugin that allows both public access and cookie auth
|
||||
#### A plugin that disabled the default auth policy and handles auth by itself
|
||||
|
||||
```ts
|
||||
export default createBackendPlugin({
|
||||
@@ -236,15 +264,8 @@ export default createBackendPlugin({
|
||||
},
|
||||
async init({ http }) {
|
||||
http.use(await createRouter(/* ... */));
|
||||
|
||||
http.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'user-cookie',
|
||||
});
|
||||
|
||||
// Unauthenticated access takes precedence, the /public endpoint does not require cookie auth
|
||||
http.addAuthPolicy({
|
||||
path: '/public',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
@@ -274,16 +295,19 @@ export interface HttpAuthService {
|
||||
req: Request,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<
|
||||
BackstageCredentials<BackstageHttpAccessToPrincipalTypesMapping[TAllowed]>
|
||||
>;
|
||||
|
||||
// The cookie issued by this method must be consumable by the `credentials` method, which in turn
|
||||
// should create a credentials object that can be passed to the `getPluginRequestToken` method.
|
||||
// The issued token must then in turn be a valid token for a user principal with full access.
|
||||
issueUserCookie(res: Response): Promise<void>;
|
||||
issueUserCookie(
|
||||
res: Response,
|
||||
options?: {
|
||||
// If credentials are not provided, they will be read from the request
|
||||
credentials?: BackstageCredentials<BackstageUserPrincipal>;
|
||||
},
|
||||
): Promise<{ expiresAt: Date }>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -377,41 +401,22 @@ router.get('/read-data', (req, res) => {
|
||||
});
|
||||
```
|
||||
|
||||
#### Issuing a cookie and allowing user cookie auth on a separate endpoint
|
||||
#### Using limited user tokens to access user info
|
||||
|
||||
```ts
|
||||
router.get('/cookie', async (req, res) => {
|
||||
await httpAuth.issueUserCookie(res); // If this is a service call it'll throw
|
||||
res.json({ ok: true });
|
||||
router.get('/read-data', (req, res) => {
|
||||
const credentials = await httpAuth.credentials(req, {
|
||||
allow: ['user'],
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
|
||||
const { userEntityRef, ownershipEntityRefs } = await userInfo.getUserInfo(
|
||||
credentials,
|
||||
);
|
||||
|
||||
console.log(`User ref=${userEntityRef} ownership=${ownershipEntityRefs}`);
|
||||
// ...
|
||||
});
|
||||
|
||||
// Allowing cookie auth is a separate step where you call the addAuthPolicy method
|
||||
// of the httpRouter API in your plugin setup code.
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/static',
|
||||
allow: 'user-cookie',
|
||||
});
|
||||
|
||||
// Separate endpoint that serves static content, allowing user cookie auth as
|
||||
// well as the default user and service auth methods
|
||||
router.use('/static', express.static(staticContentDir));
|
||||
```
|
||||
|
||||
#### Passing along user identity from a cookie in an upstream request
|
||||
|
||||
```ts
|
||||
router.get(
|
||||
'/read-data',
|
||||
httpAuth.middleware({ allow: ['user-cookie'] }),
|
||||
(req, res) => {
|
||||
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
|
||||
const { ownershipEntityRefs } = await userInfo.getUserInfo(credentials);
|
||||
console.log(
|
||||
`User ref=${credentials.userEntityRef} ownership=${ownershipEntityRefs}`,
|
||||
);
|
||||
// ...
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Access Control Configuration
|
||||
@@ -425,6 +430,79 @@ backend:
|
||||
|
||||
The exact impact that this has is that it disables the check in the `HttpRouterService` implementation, effectively applying the `unauthenticated` access level to all routes. Furthermore, it will also change `AuthService` so that the `getPluginRequestToken()` method will now issue an empty token for a `'none'` principal, rather than throwing.
|
||||
|
||||
### Token Details
|
||||
|
||||
Note that this section is NOT normative. It illustrates the token shapes and major token flows that are involved in this proposal, but intentionally leaves out some low level details and is subject to change.
|
||||
|
||||
#### Backstage Identity Tokens
|
||||
|
||||
These are the regular tokens, commonly in short referred to just as "Backstage Tokens", that the auth backend generates for the user during sign-in. These are sent along with calls to backend plugins to identify the user. This BEP does not aim to change the shape of these tokens; this section is only here for informative purposes to convey what pieces of information that are at play.
|
||||
|
||||
This is a JWT token.
|
||||
|
||||
```yaml
|
||||
# Header
|
||||
{
|
||||
"alg": "ES256",
|
||||
"kid": "4f5a0543-894a-4176-b0b7-699a7026b72f"
|
||||
}
|
||||
# Payload
|
||||
{
|
||||
"iss": "http://localhost:7007/api/auth",
|
||||
"sub": "user:default/example-user",
|
||||
"ent": ["user:default/example-user", "group:default/my-team"],
|
||||
"aud": "backstage",
|
||||
"iat": 1708333140,
|
||||
"exp": 1708336740
|
||||
}
|
||||
```
|
||||
|
||||
The key ID is some random UUID. The `iss` (issuer) is the external base URL of your auth backend. Note that it uses the `ES256` asymmetric signature algorithm, and the auth backend exposes a JWKS that contains the public parts of the signing keys. The `sub` is an entity ref denoting who the signed in user is, and the `ent` is an array of entity refs that they claim ownership through. The `aud` (audience) is hardcoded to the string `"backstage"` always.
|
||||
|
||||
#### Legacy Service Tokens
|
||||
|
||||
These are the tokens that have been used for backend-to-backend communications before this BEP, and they will likely have changes as part of this work.
|
||||
|
||||
This is a JWT token.
|
||||
|
||||
```yaml
|
||||
# Header
|
||||
{
|
||||
"alg": "HS256"
|
||||
}
|
||||
# Payload
|
||||
{
|
||||
"sub": "backstage-server",
|
||||
"exp": 1708337056
|
||||
}
|
||||
```
|
||||
|
||||
Note that unlike the identity token in the previous section, it uses the `HS256` symmetric signature algorithm. The key used is the first of the `backend.auth.keys` entries in your `app-config`, which is a shared secret among all backend plugins, enabling them to know that the caller is a legitimate one. But the token does not contain any information about who the caller is (it's just a generic `"backstage-server"`), nor who the receiver (audience) is.
|
||||
|
||||
#### New Cookie Token Flow
|
||||
|
||||
Some plugins serve static content that the browser engine requests directly, e.g. the TechDocs plugin. Those calls cannot easily have a bearer token attached to them. For these use cases a cookie based flow will be used instead.
|
||||
|
||||

|
||||
|
||||
The frontend part of the plugin ensures that a cookie endpoint on the backend part of the plugin is called before attempting to render static content. This endpoint validates the user's identity token and sets a corresponding cookie on the response. Subsequent requests for getting static content will automatically have this cookie attached to them by the browser.
|
||||
|
||||
We intentionally do not specify here how the cookie token is acquired. It might be issued by the plugin itself or by the auth backend depending on how the architecture evolves, but this does not have any effect on plugin code.
|
||||
|
||||
The cookie token contains the user's identifying information just like the identity cookie but is severely limited. It has the plugin itself specified as its audience. Thus, this token is not usable in any bearer token context, nor as a cookie toward any other plugin.
|
||||
|
||||
#### New Service OBO Token Flow
|
||||
|
||||
When a backend service needs to in turn make a request to another upstream service to fulfil the original request, it uses an On-Behalf-Of (OBO) token for the purpose.
|
||||
|
||||

|
||||
|
||||
The initial request in this picture is a frontend plugin, but the same concept applies if it is initiated by a service. The scaffolder backend in this example acquires an OBO token to be able to talk to the catalog plugin.
|
||||
|
||||
We intentionally do not specify here how the OBO token is acquired. It might be issued by the plugin itself or by the auth backend depending on how the architecture evolves, but this does not have any effect on plugin code.
|
||||
|
||||
The OBO token specifies the target service as its audience and itself as the subject, but additionally also contains the original caller's identifying information. Thus, the target service can identify who the nearest caller is but also apply permissions that are relevant to the original caller. The token is thus scoped to not be usable toward other backend plugins.
|
||||
|
||||
## Release Plan
|
||||
|
||||
The existing `IdentityService` and `TokenManagerService` will be deprecated and instead implemented in terms of the new `AuthService`.
|
||||
@@ -433,10 +511,11 @@ The new `AuthService` and `HttpAuthService` will need backwards compatible imple
|
||||
|
||||
The backwards compatibility helpers will have the following behavior for each individual service call:
|
||||
|
||||
- `auth.authenticate(token)`: If the decoded token has the `backstage` audience, authenticate the token for a user principal using `identity.getIdentity(...)`, otherwise authenticate it using `tokenManager.authenticate(...)` and return a service principal with the subject `external:backstage-plugin`. If a no-op token manager is used then anything but a user token will be treated as a valid service token, which is consistent with existing behavior.
|
||||
- `auth.authenticate(token, options)`: If the decoded token has the `backstage` audience, authenticate the token for a user principal using `identity.getIdentity(...)`, otherwise authenticate it using `tokenManager.authenticate(...)` and return a service principal with the subject `external:backstage-plugin`. If a no-op token manager is used then anything but a user token will be treated as a valid service token, which is consistent with existing behavior. The limited access option is ignored.
|
||||
- `auth.getOwnServiceCredentials()`: Use original implementation.
|
||||
- `auth.isPrincipal()`: Use original implementation.
|
||||
- `auth.getPluginRequestToken(options)`: Same behavior as the original implementation, using the `tokenManager` to issue service tokens, with the exception that a `none` principal will translate to an empty token rather than an error in order to properly forward calls with a no-op token manager.
|
||||
- `auth.getLimitedUserToken(credentials)`: This is a no-op and returns the underlying user token with full scope.
|
||||
- `httpAuth.credentials(...)`: Use original implementation.
|
||||
- `httpAuth.issueUserCookie(...)`: This is a no-op as we do not need to support cookie auth in the legacy adapter.
|
||||
|
||||
@@ -475,9 +554,9 @@ Cons:
|
||||
- Can be extremely confusing because the top-level middleware for more lax access will also apply to the more strict access levels. For example
|
||||
|
||||
```ts
|
||||
const cookieRouter = Router();
|
||||
cookieRouter.use(rateLimit());
|
||||
http.useWithCookieAuthentication(cookieRouter);
|
||||
const publicRouter = Router();
|
||||
publicRouter.use(rateLimit());
|
||||
http.useWithoutAuthentication(publicRouter);
|
||||
|
||||
const mainRouter = Router();
|
||||
// rateLimit() will apply here too
|
||||
@@ -487,7 +566,7 @@ Cons:
|
||||
This applied to any similar way of structuring this API, such as a single `.use()` method with additional options:
|
||||
|
||||
```ts
|
||||
http.use(cookieRouter, { allow: ['user-cookie'] });
|
||||
http.use(publicRouter, { allow: ['unauthenticated'] });
|
||||
```
|
||||
|
||||
#### Separate configuration on different paths for `use`
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="391px" height="261px" viewBox="-0.5 -0.5 391 261" content="<mxfile><diagram id="Wjgsm-kAhxxm43YMIDME" name="Page-1">5VrbcqM4EP0aP8YFyGD8OHbizEN2a2ozVbv7KIOwVZGRI+Qk3q/fFkjchG8xziQ1TNUUanSBc7qPuuUM0Gz9di/wZvUHjwkbeE78NkC3A88beS78rwy7wuAHQWFYChoXJrcyPNL/iDY62rqlMckaHSXnTNJN0xjxNCWRbNiwEPy12S3hrLnqBi/1ik5leIwwI1a3v2ksV/qzwlrv74QuV2ZlXz9Y4OhpKfg21csNPDTPr+LxGpupdP9shWP+WjOhuwGaCc5lcbd+mxGmkDWoFePme56Wry1IKk8ZgPxixAtmW/3pEedPlCjQGLxZ8ZZyZ4CBF96o2+2azQVew+30dUUledzgSNlfwR3AtpJrBi1XPdb4AXZgN6Ah1UooYzPOuMjnRkmSeFEE9kwK/kRqT+JgEfiBGsFTWbM7+QV2+7s1FC9ESPJWM2kc7glfEyl20EU/Ndxqj3UNRa8V/2iibas696Yj1j63LKeugIcbjX03D57Nw1R5MRGHGHigCWE0VSRsiKCwKlHAMG3+UdmOkQRxJDEMEWWbMbzJ6CJfVSEsSLQVGX0hf5GsCFdl5VupVpqVYdhBa4xJmHTSGkQhWSTXpdUNmrwGNq1hB6tlv0tYRSOLVYvOGgkbTlOZL+dPB/5ti1Uu5IoveYpZndd2CPnqn41ocXVykF+dWJdOeTLYRk401hMba7cD616gDo9DTWLQeN1MeR42NfQziYX8pvaO6vF+TDK+FRFp0gwTLEmrn1rzIHKCMCwhrJpb2CVIuBYSP0m0inmUDbyAwftNF6AqwVLdTWG/IrBZfX2R8UkYj7ocPPQWKLjy3oG8XygywZcWGXQ+2E2RcZ3TVGbUB9bjS1Vmr7P1JxT5UBAyvKt10LRXM/9QhgpUK/0JnBYwxYx7Rjt+5+hy+UIu9ah6PtqaCPlHJio01pooJ64E6yQuR7ZO3t/9HKhInpscuIPaB7yAUqfBKWZ0mcJ9BJzm8aJ8mUIx8U0/WNM4VnOAvoGs4Urv7GBUIaXLoVA353hNmcJjBiBSlRE6f5LX7mAy/tkOprJS0osP6uVGV5A5QxDHoMlF0TrPDy2Gb9xRY9Ybd9KcgidJRi4m17k0UAFTsftHNYYO8ozhX0XccDIZG8Ptm6ayaO3qrZqE5sYYZysSl/PHJtngG5IWljlVX3R7cFf6ZELhOecIRTu+zeizhQIUZ4i8pn9OJsMwvJpceJZH5W7qlPUyT3Pwsw1Ps8+gHbYHlVFxsTzcgD4Eo7AfRWi6xE27nu5HEM4rxdosLLiUfA0PmKJwWp7ymBxH60ctqCOGs4xGjbh22yJwlLHumG+WGmiPDtTzIbONXkgVhOsQoXDsTILxeOROINtrRHPY07YPEw1diOfyOimqL5ay8ICUvcfn7EOdhTnUAb8DIqIi8iTJX0uQ5y3Jijf+fNphAqiH1ML1fLeVBPSjJOOWkrRm6EdJ7Hqr3AIklD2pRV+uFHnMO8eL5BoR4yucpXXQZ+nNXubKDcQETKABP1Lnuj2UXiO79PpdYXfDD4TdPlfbFgJGY3h9Kne/If6jj8Pft6tUc4qnvhieJFifQwbPW/Wz0fQ7YS9E7QOVyRz34eh5SwWpZ62au/bB4AJnQJ1OajXje7oaC3xK8UrGfKDeqs6Z7ipr197GSKImy8BxaLp8yFu3XjPVWjAePaldruFwB0vntrvtcZ5aEejmJ1u1ItDzS8M5ReDhBHJPune6r6rEHDmjXrZTFLQOAManpXfn5mHlz2qtdfZmi+3+k7NK0CCAytHKLau5huZE9Ui6Wc3ca37h24WmFUqfpFJ5j59+QFXiNdPA8meo86uSydA/PNW1KpFDHv0en/o61e/X8Cn03nMr26esqa7kU+jQQd1xn4Jm9WcpRffqL3/Q3f8=</diagram></mxfile>" style="background-color: rgb(255, 255, 255);">
|
||||
<defs/>
|
||||
<g>
|
||||
<path d="M 0 60 L 80 60 L 80 75 L 70 90 L 0 90 Z" fill="#fff2cc" stroke="#d6b656" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 80 60 L 390 60 L 390 180 L 0 180 L 0 90" fill="none" stroke="#d6b656" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 75px; margin-left: 1px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
cookie flow
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="40" y="79" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
cookie flow
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="80" y="0" width="80" height="40" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all"/>
|
||||
<path d="M 120 40 L 120 260" fill="none" stroke="#6c8ebf" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 20px; margin-left: 81px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
Browser
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="120" y="24" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
Browser
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="115" y="90" width="10" height="60" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
|
||||
<rect x="240" y="0" width="80" height="40" fill="#d5e8d4" stroke="#82b366" pointer-events="all"/>
|
||||
<path d="M 280 40 L 280 260" fill="none" stroke="#82b366" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 20px; margin-left: 241px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
Techdocs
|
||||
<br/>
|
||||
Backend
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="280" y="24" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
Techdocs...
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="275" y="100" width="10" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
|
||||
<path d="M 125 100 L 190 100 Q 200 100 210 100 L 268.63 100" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 273.88 100 L 266.88 103.5 L 268.63 100 L 266.88 96.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 80px; margin-left: 186px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 8px; font-family: "Courier New"; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;">
|
||||
GET /cookie
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="186" y="83" fill="rgb(0, 0, 0)" font-family="Courier New" font-size="8px" text-anchor="middle">
|
||||
GET /cookie
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 275 140 L 210 140 Q 200 140 190 139.98 L 127.56 139.88" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
|
||||
<path d="M 133.44 136.39 L 126.44 139.88 L 133.43 143.39" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 120px; margin-left: 200px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 8px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;">
|
||||
set cookie on response
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="200" y="123" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="8px" text-anchor="middle">
|
||||
set cookie on response
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 120.34 220 L 190 220 Q 200 220 210 220 L 273.13 220" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
|
||||
<path d="M 278.38 220 L 271.38 223.5 L 273.13 220 L 271.38 216.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 200px; margin-left: 203px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 8px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;">
|
||||
browser static content requests
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="203" y="203" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="8px" text-anchor="middle">
|
||||
browser static content requests
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="160" y="207" width="80" height="10" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 212px; margin-left: 161px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 7px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
cookie token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="200" y="214" fill="#000000" font-family="Helvetica" font-size="7px" text-anchor="middle">
|
||||
cookie token
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="160" y="127" width="80" height="10" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 132px; margin-left: 161px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 7px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
cookie token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="200" y="134" fill="#000000" font-family="Helvetica" font-size="7px" text-anchor="middle">
|
||||
cookie token
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="160" y="87" width="80" height="10" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 92px; margin-left: 161px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 7px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
user identity token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="200" y="94" fill="#000000" font-family="Helvetica" font-size="7px" text-anchor="middle">
|
||||
user identity token
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 286 110 L 310 110 L 310 130.4 L 294.45 130.37" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 287.45 130.36 L 294.45 126.87 L 294.44 133.87 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 119px; margin-left: 314px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: left;">
|
||||
<div style="display: inline-block; font-size: 7px; font-family: "Courier New"; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;">
|
||||
<font face="Helvetica">
|
||||
acquire cookie token
|
||||
<br/>
|
||||
based on user token
|
||||
<br/>
|
||||
</font>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="314" y="121" fill="#000000" font-family="Courier New" font-size="7px">
|
||||
acquire cookie token...
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 120.34 230 L 190 230 Q 200 230 210 230 L 273.13 230" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
|
||||
<path d="M 278.38 230 L 271.38 233.5 L 273.13 230 L 271.38 226.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 120.34 240 L 190 240 Q 200 240 210 240 L 273.13 240" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
|
||||
<path d="M 278.38 240 L 271.38 243.5 L 273.13 240 L 271.38 236.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
</g>
|
||||
<switch>
|
||||
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
|
||||
<a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank">
|
||||
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
|
||||
Text is not SVG - cannot display
|
||||
</text>
|
||||
</a>
|
||||
</switch>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,203 @@
|
||||
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="401px" height="251px" viewBox="-0.5 -0.5 401 251" content="<mxfile><diagram id="Wjgsm-kAhxxm43YMIDME" name="Page-1">7VrbcuI4EP0aHkP5jnkcIGQeMrupzVbt7qOwhVFFWIwsh7Bfvy1bvsgywSQwyWyNU5Wy2rrY3eecbikZufPtyx1Hu803FmM6cqz4ZeQuRo7jOTb8loZDafCDoDQknMSlyW4Mj+RfrIyWsuYkxpnWUTBGBdnpxoilKY6EZkOcs73ebc2ovuoOJWpFqzE8Rohio9tfJBYb9Vlhq/dXTJJNtbKvHqxQ9JRwlqdquZHjrourfLxF1VSqf7ZBMdu3TO7tyJ1zxkR5t32ZYyo9W3mtHLc88rR+bY5TMWSA45cjnhHN1afPpPcwV+8nDpVL4FV38jbf0nuyxpSk0JrtMCdbLKC/u6DK/NDYZvsNEfhxhyI5dA9YAdtGbCm0bLiF+AkEQ3jdphTtMrIqVrXAwnGU84w84z9wVsJEWlku5ErzOvzSuCaUzhllvHhhN0Y4XEdgzwRnT7j1JIhCvFrLEbB8y24VF9hNNyrPPmMu8EvLpNx6hxl8MT9Al+ppoEKsGFA19w2cKjRtWkhyKiQhheCknrkJI9yoSPZH1fWMqBrhbAVhx0gqiuX82chfdKLKuNiwhKWItuPa8fXalz+mR8urNwbF1evrGpSDne36mq+npq/tHl/b3iV8bRu+Bh1ZS8WRJAooLDZbybtE3s1AIDCow8/PLh+HsdcX2dBZuUVkr8gu1/lAdgU/Nbvc852ts8u2BtLLuYCzA7fH2SWppDNkbi/RD9bvuUybs6+YPmNBItSYKvah6HtOOJaxwvyZRPKOrVhRXQAtTbquUIZj2QmeWXmRFo91rSzwReWbVeYONHAMlYZqNtG/baxtBiNKklSSH6/lZBlQnaTJfdFaQMhmoCVfZL0DzRVl0ZPktCw/cFzRFt5libaEyujNWc5J8RG/4b16qIqvySuEBezww9/ylcZ2gbfC8I9cYez4tWHxotYsW4d2q4XuwkjRCtNZXS1Vq6as0L3SadJTegmIeIIrGAfHoH0UxzfW2HItlRs5pkiA8OmlYw9S1XQPksbNXF6gc8LuYD0DT0dYDWrgDrFCh1Y3JQ5Hl5lYvcssB3Z3te5wU67fPzjoH1y/a+l945OaeaqObL3OsBh1WV4HZJjKTk6rbJtKCjkt7hxNM2Vs9FKpD1od/Jm4Og9D58a+zmBVQIJXovkGD3tm5fLw++OfIynbS4Gyp6zX4feSuf0qFYGni/wlcwsIMP2iHmxJHBfSxmWZgZr6w0yOLUkKCzET4GKWDlGzHkGoQNTNdfXOUb3LqL396tMOkA7L6nD+IkJyY3varDf2VJ/iImyqKt23s6mVA6xuDgjDN+WAGGWbIk/ZeiJjO8ivhWVJ5BctXi0afxhLj6qu6+shrIl7IhmYjLf8savSbnF15p1Mx9PAfHxCoN8CF8eAS5ZHEc4+gySYSKjR/W6ayxIh8MILMVsn9lV4be5F5kggypJfW8/jW88enBjCchQiXvhxW09vQFH0ybaeJ3xdAnjw1nMybOt5iYOdSgXeW4Aau5gBULtuYel5OoKds7YJbuD3jj472/n2iYkumM+mRizvblWlC7EggmDw13I8Hn+C/Dagxh2c6DpkC18l2+Vr3LqmDa+Q+3yzSDmToFpN6+g17XQ6+UE17ScUCHc6DkKdnJNzNMK3PZigKVTt3rnOVgyQnrHVvnSwOsF0HPrX0hDfPIkUeLuDOMhTxEJFDp9APd4lECWl/jfVsW+mcHWGS+IqYNVxbidw+iHqiWK3e4x64b9DvqtcdTrJ3q62DCfq1e6B5ltKKN9Mu0fO3a/o/Y/dLFidUsueXMv70Gz+b6AkS/OvGe7tfw==</diagram></mxfile>" style="background-color: rgb(255, 255, 255);">
|
||||
<defs/>
|
||||
<g>
|
||||
<rect x="0" y="0" width="80" height="40" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all"/>
|
||||
<path d="M 40 40 L 40 250" fill="none" stroke="#6c8ebf" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 20px; margin-left: 1px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
Browser
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="40" y="24" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
Browser
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="35" y="90" width="10" height="140" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
|
||||
<rect x="160" y="0" width="80" height="40" fill="#d5e8d4" stroke="#82b366" pointer-events="all"/>
|
||||
<path d="M 200 40 L 200 250" fill="none" stroke="#82b366" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 20px; margin-left: 161px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
Scaffolder
|
||||
<br/>
|
||||
Backend
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="200" y="24" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
Scaffolder...
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="195" y="100" width="10" height="120" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
|
||||
<path d="M 206 110 L 230 110 L 230 130.4 L 214.45 130.37" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 207.45 130.36 L 214.45 126.87 L 214.44 133.87 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 119px; margin-left: 234px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: left;">
|
||||
<div style="display: inline-block; font-size: 7px; font-family: "Courier New"; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;">
|
||||
<font face="Helvetica">
|
||||
acquire service obo token
|
||||
<br/>
|
||||
based on user token
|
||||
<br/>
|
||||
</font>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="234" y="121" fill="#000000" font-family="Courier New" font-size="7px">
|
||||
acquire service obo token...
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 45 100 L 110 100 Q 120 100 130 100 L 188.63 100" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 193.88 100 L 186.88 103.5 L 188.63 100 L 186.88 96.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 80px; margin-left: 106px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 8px; font-family: "Courier New"; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;">
|
||||
POST /tasks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="106" y="83" fill="rgb(0, 0, 0)" font-family="Courier New" font-size="8px" text-anchor="middle">
|
||||
POST /tasks
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 194 220 L 47.57 219.96" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
|
||||
<path d="M 53.45 216.46 L 46.45 219.96 L 53.45 223.46" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 211px; margin-left: 124px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 8px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;">
|
||||
success
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="124" y="214" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="8px" text-anchor="middle">
|
||||
success
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="320" y="0" width="80" height="40" fill="#d5e8d4" stroke="#82b366" pointer-events="all"/>
|
||||
<path d="M 360 40 L 360 250" fill="none" stroke="#82b366" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 20px; margin-left: 321px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
Catalog
|
||||
<br/>
|
||||
Backend
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="360" y="24" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
Catalog...
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="355" y="170" width="10" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
|
||||
<path d="M 205 170 L 270 170 Q 280 170 290 170 L 348.63 170" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 353.88 170 L 346.88 173.5 L 348.63 170 L 346.88 166.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 151px; margin-left: 280px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 8px; font-family: "Courier New"; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;">
|
||||
GET /entities/...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="280" y="154" fill="rgb(0, 0, 0)" font-family="Courier New" font-size="8px" text-anchor="middle">
|
||||
GET /entities/...
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 354.68 210 L 289.68 210 Q 279.68 210 269.68 209.98 L 207.24 209.85" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
|
||||
<path d="M 213.13 206.37 L 206.12 209.85 L 213.11 213.37" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 201px; margin-left: 284px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 8px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;">
|
||||
template entity
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="284" y="204" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="8px" text-anchor="middle">
|
||||
template entity
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="80" y="86" width="80" height="10" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 91px; margin-left: 81px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 7px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
user identity token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="120" y="93" fill="#000000" font-family="Helvetica" font-size="7px" text-anchor="middle">
|
||||
user identity token
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="240" y="157" width="80" height="10" fill="#d5e8d4" stroke="#82b366" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 162px; margin-left: 241px;">
|
||||
<div data-drawio-colors="color: #000000; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 7px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
service obo token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="280" y="164" fill="#000000" font-family="Helvetica" font-size="7px" text-anchor="middle">
|
||||
service obo token
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
</g>
|
||||
<switch>
|
||||
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
|
||||
<a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank">
|
||||
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
|
||||
Text is not SVG - cannot display
|
||||
</text>
|
||||
</a>
|
||||
</switch>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -25,9 +25,14 @@ auth:
|
||||
providers:
|
||||
cfaccess:
|
||||
teamName: <Team Name>
|
||||
serviceTokens:
|
||||
- token: '1uh2fh19efvfh129f1f919u21f2f19jf2.access'
|
||||
subject: 'bot-user@your-company.com'
|
||||
```
|
||||
|
||||
You can find the team name in the Cloudflare Zero Trust dashboard.
|
||||
You can find the team name in the Cloudflare Zero Trust dashboard. The Service
|
||||
Tokens section is optional -- you only need it if you have some Cloudflare
|
||||
Service Tokens that you want to be able to log in to your Backstage instance.
|
||||
|
||||
This config section must be in place for the provider to load at all. Now let's
|
||||
add the provider itself.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: provider
|
||||
title: Guest Authentication Provider
|
||||
sidebar_label: Guest
|
||||
description: Adding a guest authentication provider in Backstage
|
||||
---
|
||||
|
||||
Audience: Admins or developers
|
||||
|
||||
## Summary
|
||||
|
||||
The goal of this guide is to get you set up with a guest authentication provider that emits tokens. This is different than the old guest authentication that is purely stored on the frontend and does not have tokens. The main reason you'd want to use this provider is to use permissioned plugins.
|
||||
|
||||
:::caution
|
||||
This provider should only ever be enabled for `development`. To prevent unauthorized access to your data, this package is _explicitly_ disabled for non-development environments.
|
||||
:::
|
||||
|
||||
## Installation
|
||||
|
||||
### Backend
|
||||
|
||||
:::note
|
||||
This will only work with the new backend system. There is no support for this in the old backend.
|
||||
:::
|
||||
|
||||
Add the `@backstage/plugin-auth-backend-module-guest-provider` to your backend installation.
|
||||
|
||||
```
|
||||
yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-guest-provider
|
||||
```
|
||||
|
||||
Then, add it to your backend's `index.ts` file,
|
||||
|
||||
```diff
|
||||
const backend = createBackend();
|
||||
|
||||
backend.add('@backstage/plugin-auth-backend');
|
||||
+backend.add('@backstage/plugin-auth-backend-module-guest-provider');
|
||||
|
||||
await backend.start();
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
Add the following to your `SignInPage` providers,
|
||||
|
||||
```diff
|
||||
const providers = [
|
||||
+ 'guest',
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Similar to the other authentication providers, you have to enable the provider in config. Add the following to your `app-config.local.yaml`,
|
||||
|
||||
```diff
|
||||
auth:
|
||||
providers:
|
||||
+ guest:
|
||||
+ userEntityRef: user:default/guest
|
||||
+ development: {}
|
||||
```
|
||||
|
||||
We need to specify that the provider is enabled for the given environment, and as there are no config values for this provider yet, you can just specify an empty object.
|
||||
@@ -56,8 +56,7 @@ Just like plugins, modules also have access to services and can depend on their
|
||||
## Package structure
|
||||
|
||||
A detailed explanation of the package architecture can be found in the
|
||||
[Backstage Architecture
|
||||
Overview](../../overview/architecture-overview.md#package-architecture). The
|
||||
[Backstage Architecture Overview](../../overview/architecture-overview.md#package-architecture). The
|
||||
most important packages to consider for this system are the following:
|
||||
|
||||
- `plugin-<pluginId>-backend` houses the implementation of the backend plugins
|
||||
|
||||
@@ -632,7 +632,7 @@ A basic installation of the events plugin looks as follows.
|
||||
```ts title="packages/backend/src/index.ts"
|
||||
const backend = createBackend();
|
||||
/* highlight-add-next-line */
|
||||
backend.add(import('@backstage/plugin-events-backend'));
|
||||
backend.add(import('@backstage/plugin-events-backend/alpha'));
|
||||
```
|
||||
|
||||
If you have other customizations made to `plugins/events.ts`, such as adding
|
||||
@@ -646,6 +646,7 @@ depends on the appropriate extension point and interacts with it.
|
||||
|
||||
```ts title="packages/backend/src/index.ts"
|
||||
/* highlight-add-start */
|
||||
import { eventsServiceRef } from '@backstage/plugin-events-node';
|
||||
import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha';
|
||||
import { createBackendModule } from '@backstage/backend-plugin-api';
|
||||
/* highlight-add-end */
|
||||
@@ -663,7 +664,28 @@ const eventsModuleCustomExtensions = createBackendModule({
|
||||
async init({ events /* ..., other dependencies */ }) {
|
||||
// Here you have the opportunity to interact with the extension
|
||||
// point before the plugin itself gets instantiated
|
||||
events.addSubscribers(new MySubscriber()); // just an example
|
||||
events.addHttpPostIngress({
|
||||
// ...
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
/* highlight-add-end */
|
||||
|
||||
/* highlight-add-start */
|
||||
const otherPluginModuleCustomExtensions = createBackendModule({
|
||||
pluginId: 'other-plugin', // name of the plugin that the module is targeting
|
||||
moduleId: 'custom-extensions',
|
||||
register(env) {
|
||||
env.registerInit({
|
||||
deps: {
|
||||
events: eventsServiceRef,
|
||||
// ... and other dependencies as needed
|
||||
},
|
||||
async init({ events /* ..., other dependencies */ }) {
|
||||
// Here you have the opportunity to interact with the extension
|
||||
// point before the plugin itself gets instantiated
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -671,17 +693,11 @@ const eventsModuleCustomExtensions = createBackendModule({
|
||||
/* highlight-add-end */
|
||||
|
||||
const backend = createBackend();
|
||||
backend.add(import('@backstage/plugin-events-backend'));
|
||||
backend.add(import('@backstage/plugin-events-backend/alpha'));
|
||||
/* highlight-add-next-line */
|
||||
backend.add(eventsModuleCustomExtensions());
|
||||
```
|
||||
|
||||
This also requires that you have a dependency on the corresponding node package,
|
||||
if you didn't already have one.
|
||||
|
||||
```bash
|
||||
# from the repository root
|
||||
yarn --cwd packages/backend add @backstage/plugin-events-node
|
||||
/* highlight-add-next-line */
|
||||
backend.add(otherPluginModuleCustomExtensions());
|
||||
```
|
||||
|
||||
Here we've placed the module directly in the backend index file just to get
|
||||
|
||||
@@ -451,7 +451,7 @@ You can generate an isomorphic plugin package by running:`yarn new --select plug
|
||||
or you can run `yarn new` and then select "plugin-common" from the list of options
|
||||
|
||||
There's at this point no existing templates for generating isomorphic plugins
|
||||
using the `@backstage/cli`. Perhaps the simplest wat to get started right now is
|
||||
using the `@backstage/cli`. Perhaps the simplest way to get started right now is
|
||||
to copy the contents of one of the existing packages in the main repository,
|
||||
such as `plugins/scaffolder-common`, and rename the folder and file contents to
|
||||
the desired name. This example uses _foobar_ as the plugin name so the plugin
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: writing-tests-for-actions
|
||||
title: Writing Tests For Actions
|
||||
description: How to write tests for actions
|
||||
---
|
||||
|
||||
Once you created a new action, your own custom one, or you would like to contribute new actions, you have to cover it with
|
||||
Unit tests to be sure that your actions do what they suppose to do.
|
||||
|
||||
Make sure that you cover the most of scenario's, which could happen with the action.
|
||||
One of indispensable part of the test is to supply the context to a handler of action for the execution.
|
||||
We encourage you to use a utility method for that, so your tests are immune to structural changes of context.
|
||||
What is inevitably going to happen during the time.
|
||||
|
||||
Example how to use it:
|
||||
|
||||
```typescript
|
||||
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
|
||||
|
||||
const mockContext = createMockActionContext({
|
||||
input: { repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org' },
|
||||
});
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||
expect(mockContext.output).toHaveBeenCalledWith(
|
||||
'remoteUrl',
|
||||
'https://dev.azure.com/organization/project/_git/repo',
|
||||
);
|
||||
```
|
||||
|
||||
One thing to be aware about: if you would like to call `createMockActionContext` inside `it`,
|
||||
you have to provide a `workspacePath`. By default, `createMockActionContext` uses
|
||||
`import { createMockDirectory } from '@backstage/backend-test-utils';` to create it for you.
|
||||
This implementation contains a hook inside which creates this limitation. So in this case you can do then:
|
||||
|
||||
```typescript
|
||||
describe('github:autolinks:create', async () => {
|
||||
const workspacePath = createMockDirectory().resolve('workspace');
|
||||
// ...
|
||||
|
||||
it('should call the githubApis for creating alphanumeric autolink reference', async () => {
|
||||
// ...
|
||||
await action.handler(
|
||||
createMockActionContext({
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repo&owner=owner',
|
||||
keyPrefix: 'TICKET-',
|
||||
urlTemplate: 'https://example.com/TICKET?query=<num>',
|
||||
},
|
||||
workspacePath,
|
||||
}),
|
||||
);
|
||||
//...
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -4,56 +4,42 @@ title: Customize the look-and-feel of your App
|
||||
description: Documentation on Customizing look and feel of the App
|
||||
---
|
||||
|
||||
Backstage ships with a default theme with a light and dark mode variant. The
|
||||
themes are provided as a part of the
|
||||
[`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) package,
|
||||
which also includes utilities for customizing the default theme, or creating
|
||||
completely new themes.
|
||||
Backstage ships with a default theme with a light and dark mode variant. The themes are provided as a part of the [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) package, which also includes utilities for customizing the default theme, or creating completely new themes.
|
||||
|
||||
## Creating a Custom Theme
|
||||
|
||||
The easiest way to create a new theme is to use the `createTheme` function
|
||||
exported by the
|
||||
[`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) package. You
|
||||
can use it to override some basic parameters of the default theme such as the
|
||||
color palette and font.
|
||||
The easiest way to create a new theme is to use the `createUnifiedTheme` function exported by the [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) package. You can use it to override some basic parameters of the default theme such as the color palette and font.
|
||||
|
||||
For example, you can create a new theme based on the default light theme like
|
||||
this:
|
||||
For example, you can create a new theme based on the default light theme like this:
|
||||
|
||||
```ts
|
||||
import { createTheme, lightTheme } from '@backstage/theme';
|
||||
```ts title="packages/app/src/theme/myTheme.ts"
|
||||
import {
|
||||
createBaseThemeOptions,
|
||||
createUnifiedTheme,
|
||||
palettes,
|
||||
} from '@backstage/theme';
|
||||
|
||||
const myTheme = createTheme({
|
||||
palette: lightTheme.palette,
|
||||
const myTheme = createUnifiedTheme({
|
||||
...createBaseThemeOptions({
|
||||
palette: palettes.light,
|
||||
}),
|
||||
fontFamily: 'Comic Sans MS',
|
||||
defaultPageTheme: 'home',
|
||||
});
|
||||
```
|
||||
|
||||
If you want more control over the theme, and for example customize font sizes
|
||||
and margins, you can use the lower-level `createThemeOverrides` function
|
||||
exported by [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme)
|
||||
in combination with
|
||||
[`createTheme`](https://material-ui.com/customization/theming/#createmuitheme-options-args-theme)
|
||||
from [`@material-ui/core`](https://www.npmjs.com/package/@material-ui/core). See
|
||||
the "Overriding Backstage and Material UI css rules" section below.
|
||||
> Note: we recommend creating a `theme` folder in `packages/app/src` to place your theme file to keep things nicely organized.
|
||||
|
||||
You can also create a theme from scratch that matches the `BackstageTheme` type
|
||||
exported by [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme).
|
||||
See the
|
||||
[Material UI docs on theming](https://material-ui.com/customization/theming/)
|
||||
for more information about how that can be done.
|
||||
You can also create a theme from scratch that matches the `BackstageTheme` type exported by [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme). See the
|
||||
[Material UI docs on theming](https://material-ui.com/customization/theming/) for more information about how that can be done.
|
||||
|
||||
## Using your Custom Theme
|
||||
|
||||
To add a custom theme to your Backstage app, you pass it as configuration to
|
||||
`createApp`.
|
||||
To add a custom theme to your Backstage app, you pass it as configuration to `createApp`.
|
||||
|
||||
For example, adding the theme that we created in the previous section can be
|
||||
done like this:
|
||||
For example, adding the theme that we created in the previous section can be done like this:
|
||||
|
||||
```tsx
|
||||
```tsx title="packages/app/src/App.tsx"
|
||||
import { createApp } from '@backstage/app-defaults';
|
||||
import { ThemeProvider } from '@material-ui/core/styles';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
@@ -68,70 +54,68 @@ const app = createApp({
|
||||
variant: 'light',
|
||||
icon: <LightIcon />,
|
||||
Provider: ({ children }) => (
|
||||
<ThemeProvider theme={myTheme}>
|
||||
<CssBaseline>{children}</CssBaseline>
|
||||
</ThemeProvider>
|
||||
<UnifiedThemeProvider theme={myTheme} children={children} />
|
||||
),
|
||||
}]
|
||||
})
|
||||
```
|
||||
|
||||
Note that your list of custom themes overrides the default themes. If you still
|
||||
want to use the default themes, they are exported as `lightTheme` and
|
||||
`darkTheme` from
|
||||
[`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme).
|
||||
Note that your list of custom themes overrides the default themes. If you still want to use the default themes, they are exported as `themes.light` and `themes.light` from [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme).
|
||||
|
||||
## Example of a custom theme
|
||||
|
||||
```ts
|
||||
```ts title="packages/app/src/theme/myTheme.ts"
|
||||
import {
|
||||
createTheme,
|
||||
createBaseThemeOptions,
|
||||
createUnifiedTheme,
|
||||
genPageTheme,
|
||||
lightTheme,
|
||||
palettes,
|
||||
shapes,
|
||||
} from '@backstage/theme';
|
||||
|
||||
const myTheme = createTheme({
|
||||
palette: {
|
||||
...lightTheme.palette,
|
||||
primary: {
|
||||
main: '#343b58',
|
||||
const myTheme = createUnifiedTheme({
|
||||
...createBaseThemeOptions({
|
||||
palette: {
|
||||
...palettes.light,
|
||||
primary: {
|
||||
main: '#343b58',
|
||||
},
|
||||
secondary: {
|
||||
main: '#565a6e',
|
||||
},
|
||||
error: {
|
||||
main: '#8c4351',
|
||||
},
|
||||
warning: {
|
||||
main: '#8f5e15',
|
||||
},
|
||||
info: {
|
||||
main: '#34548a',
|
||||
},
|
||||
success: {
|
||||
main: '#485e30',
|
||||
},
|
||||
background: {
|
||||
default: '#d5d6db',
|
||||
paper: '#d5d6db',
|
||||
},
|
||||
banner: {
|
||||
info: '#34548a',
|
||||
error: '#8c4351',
|
||||
text: '#343b58',
|
||||
link: '#565a6e',
|
||||
},
|
||||
errorBackground: '#8c4351',
|
||||
warningBackground: '#8f5e15',
|
||||
infoBackground: '#343b58',
|
||||
navigation: {
|
||||
background: '#343b58',
|
||||
indicator: '#8f5e15',
|
||||
color: '#d5d6db',
|
||||
selectedColor: '#ffffff',
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
main: '#565a6e',
|
||||
},
|
||||
error: {
|
||||
main: '#8c4351',
|
||||
},
|
||||
warning: {
|
||||
main: '#8f5e15',
|
||||
},
|
||||
info: {
|
||||
main: '#34548a',
|
||||
},
|
||||
success: {
|
||||
main: '#485e30',
|
||||
},
|
||||
background: {
|
||||
default: '#d5d6db',
|
||||
paper: '#d5d6db',
|
||||
},
|
||||
banner: {
|
||||
info: '#34548a',
|
||||
error: '#8c4351',
|
||||
text: '#343b58',
|
||||
link: '#565a6e',
|
||||
},
|
||||
errorBackground: '#8c4351',
|
||||
warningBackground: '#8f5e15',
|
||||
infoBackground: '#343b58',
|
||||
navigation: {
|
||||
background: '#343b58',
|
||||
indicator: '#8f5e15',
|
||||
color: '#d5d6db',
|
||||
selectedColor: '#ffffff',
|
||||
},
|
||||
},
|
||||
}),
|
||||
defaultPageTheme: 'home',
|
||||
fontFamily: 'Comic Sans MS',
|
||||
/* below drives the header colors */
|
||||
@@ -161,16 +145,92 @@ const myTheme = createTheme({
|
||||
});
|
||||
```
|
||||
|
||||
For a more complete example of a custom theme including Backstage and
|
||||
Material UI component overrides, see the [Aperture
|
||||
theme](https://github.com/backstage/demo/blob/master/packages/app/src/theme/aperture.ts)
|
||||
from the [Backstage demo site](https://demo.backstage.io).
|
||||
For a more complete example of a custom theme including Backstage and Material UI component overrides, see the [Aperture theme](https://github.com/backstage/demo/blob/master/packages/app/src/theme/aperture.ts) from the [Backstage demo site](https://demo.backstage.io).
|
||||
|
||||
## Custom Typography
|
||||
|
||||
When creating a custom theme you can also customize various aspects of the default typography, here's an example using simplified theme:
|
||||
|
||||
```ts title="packages/app/src/theme/myTheme.ts"
|
||||
import {
|
||||
createBaseThemeOptions,
|
||||
createUnifiedTheme,
|
||||
palettes,
|
||||
} from '@backstage/theme';
|
||||
|
||||
const myTheme = createUnifiedTheme({
|
||||
...createBaseThemeOptions({
|
||||
palette: palettes.light,
|
||||
typography: {
|
||||
htmlFontSize: 16,
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
h1: {
|
||||
fontSize: 54,
|
||||
fontWeight: 700,
|
||||
marginBottom: 10,
|
||||
},
|
||||
h2: {
|
||||
fontSize: 40,
|
||||
fontWeight: 700,
|
||||
marginBottom: 8,
|
||||
},
|
||||
h3: {
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
marginBottom: 6,
|
||||
},
|
||||
h4: {
|
||||
fontWeight: 700,
|
||||
fontSize: 28,
|
||||
marginBottom: 6,
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
marginBottom: 4,
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 700,
|
||||
fontSize: 20,
|
||||
marginBottom: 2,
|
||||
},
|
||||
},
|
||||
defaultPageTheme: 'home',
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
If you wanted to only override a sub-set of the typography setting, for example just `h1` then you would do this:
|
||||
|
||||
```ts title="packages/app/src/theme/myTheme.ts"
|
||||
import {
|
||||
createBaseThemeOptions,
|
||||
createUnifiedTheme,
|
||||
defaultTypography,
|
||||
palettes,
|
||||
} from '@backstage/theme';
|
||||
|
||||
const myTheme = createUnifiedTheme({
|
||||
...createBaseThemeOptions({
|
||||
palette: palettes.light,
|
||||
typography: {
|
||||
...defaultTypography,
|
||||
htmlFontSize: 16,
|
||||
fontFamily: 'Roboto, sans-serif',
|
||||
h1: {
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
marginBottom: 10,
|
||||
},
|
||||
},
|
||||
defaultPageTheme: 'home',
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Overriding Backstage and Material UI components styles
|
||||
|
||||
When creating a custom theme you would be applying different values to
|
||||
component's css rules that use the theme object. For example, a Backstage
|
||||
component's styles might look like this:
|
||||
When creating a custom theme you would be applying different values to component's CSS rules that use the theme object. For example, a Backstage component's styles might look like this:
|
||||
|
||||
```tsx
|
||||
const useStyles = makeStyles<BackstageTheme>(
|
||||
@@ -185,83 +245,50 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
);
|
||||
```
|
||||
|
||||
Notice how the `padding` is getting its value from `theme.spacing`, that means
|
||||
that setting a value for spacing in your custom theme would affect this
|
||||
component padding property and the same goes for `backgroundImage` which uses
|
||||
`theme.page.backgroundImage`. However, the `boxShadow` property doesn't
|
||||
reference any value from the theme, that means that creating a custom theme
|
||||
wouldn't be enough to alter the `box-shadow` property or to add css rules that
|
||||
aren't already defined like a margin. For these cases you should also create an
|
||||
override.
|
||||
Notice how the `padding` is getting its value from `theme.spacing`, that means that setting a value for spacing in your custom theme would affect this component padding property and the same goes for `backgroundImage` which uses `theme.page.backgroundImage`. However, the `boxShadow` property doesn't reference any value from the theme, that means that creating a custom theme wouldn't be enough to alter the `box-shadow` property or to add css rules that aren't already defined like a margin. For these cases you should also create an override.
|
||||
|
||||
```tsx
|
||||
import { createApp } from '@backstage/core-app-api';
|
||||
import { BackstageTheme, lightTheme } from '@backstage/theme';
|
||||
/**
|
||||
* The `@backstage/core-components` package exposes this type that
|
||||
* contains all Backstage and `material-ui` components that can be
|
||||
* overridden along with the classes key those components use.
|
||||
*/
|
||||
import { BackstageOverrides } from '@backstage/core-components';
|
||||
Here's how you would do that:
|
||||
|
||||
export const createCustomThemeOverrides = (
|
||||
theme: BackstageTheme,
|
||||
): BackstageOverrides => {
|
||||
return {
|
||||
```ts title="packages/app/src/theme/myTheme.ts"
|
||||
import {
|
||||
createBaseThemeOptions,
|
||||
createUnifiedTheme,
|
||||
palettes,
|
||||
} from '@backstage/theme';
|
||||
|
||||
const myTheme = createUnifiedTheme({
|
||||
...createBaseThemeOptions({
|
||||
palette: palettes.light,
|
||||
}),
|
||||
fontFamily: 'Comic Sans MS',
|
||||
defaultPageTheme: 'home',
|
||||
components: {
|
||||
BackstageHeader: {
|
||||
header: {
|
||||
width: 'auto',
|
||||
margin: '20px',
|
||||
boxShadow: 'none',
|
||||
borderBottom: `4px solid ${theme.palette.primary.main}`,
|
||||
styleOverrides: {
|
||||
header: ({ theme }) => ({
|
||||
width: 'auto',
|
||||
margin: '20px',
|
||||
boxShadow: 'none',
|
||||
borderBottom: `4px solid ${theme.palette.primary.main}`,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const customTheme: BackstageTheme = {
|
||||
...lightTheme,
|
||||
overrides: {
|
||||
// These are the overrides that Backstage applies to `material-ui` components
|
||||
...lightTheme.overrides,
|
||||
// These are your custom overrides, either to `material-ui` or Backstage components.
|
||||
...createCustomThemeOverrides(lightTheme),
|
||||
},
|
||||
};
|
||||
|
||||
const app = createApp({
|
||||
apis: ...,
|
||||
plugins: ...,
|
||||
themes: [{
|
||||
id: 'my-theme',
|
||||
title: 'My Custom Theme',
|
||||
variant: 'light',
|
||||
Provider: ({ children }) => (
|
||||
<ThemeProvider theme={customTheme}>
|
||||
<CssBaseline>{children}</CssBaseline>
|
||||
</ThemeProvider>
|
||||
),
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Logo
|
||||
|
||||
In addition to a custom theme, you can also customize the logo displayed at the
|
||||
far top left of the site.
|
||||
In addition to a custom theme, you can also customize the logo displayed at the far top left of the site.
|
||||
|
||||
In your frontend app, locate `src/components/Root/` folder. You'll find two
|
||||
components:
|
||||
In your frontend app, locate `src/components/Root/` folder. You'll find two components:
|
||||
|
||||
- `LogoFull.tsx` - A larger logo used when the Sidebar navigation is opened.
|
||||
- `LogoIcon.tsx` - A smaller logo used when the sidebar navigation is closed.
|
||||
- `LogoIcon.tsx` - A smaller logo used when the Sidebar navigation is closed.
|
||||
|
||||
To replace the images, you can simply replace the relevant code in those
|
||||
components with raw SVG definitions.
|
||||
To replace the images, you can simply replace the relevant code in those components with raw SVG definitions.
|
||||
|
||||
You can also use another web image format such as PNG by importing it. To do
|
||||
this, place your new image into a new subdirectory such as
|
||||
`src/components/Root/logo/my-company-logo.png`, and then add this code:
|
||||
You can also use another web image format such as PNG by importing it. To do this, place your new image into a new subdirectory such as `src/components/Root/logo/my-company-logo.png`, and then add this code:
|
||||
|
||||
```tsx
|
||||
import MyCustomLogoFull from './logo/my-company-logo.png';
|
||||
@@ -408,7 +435,7 @@ For this example we'll show you how you can expand the sidebar with a sub-menu:
|
||||
|
||||
3. Then update the `@backstage/core-components` import like this:
|
||||
|
||||
```tsx
|
||||
```tsx title="packages/app/src/components/Root/Root.tsx"
|
||||
import {
|
||||
Sidebar,
|
||||
sidebarConfig,
|
||||
@@ -430,7 +457,7 @@ For this example we'll show you how you can expand the sidebar with a sub-menu:
|
||||
|
||||
4. Finally replace `<SidebarItem icon={HomeIcon} to="catalog" text="Home" />` with this:
|
||||
|
||||
```tsx
|
||||
```tsx title="packages/app/src/components/Root/Root.tsx"
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home">
|
||||
<SidebarSubmenu title="Catalog">
|
||||
<SidebarSubmenuItem
|
||||
|
||||
@@ -24,7 +24,35 @@ package.
|
||||
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-bitbucket-cloud
|
||||
```
|
||||
|
||||
### Installation without Events Support
|
||||
### Installation with New Backend System
|
||||
|
||||
```ts
|
||||
// optional if you want HTTP endpojnts to receive external events
|
||||
// backend.add(import('@backstage/plugin-events-backend/alpha'));
|
||||
// optional if you want to use AWS SQS instead of HTTP endpoints to receive external events
|
||||
// backend.add(import('@backstage/plugin-events-backend-module-aws-sqs/alpha'));
|
||||
backend.add(
|
||||
import('@backstage/plugin-events-backend-module-bitbucket-cloud/alpha'),
|
||||
);
|
||||
backend.add(
|
||||
import('@backstage/plugin-catalog-backend-module-bitbucket-cloud/alpha'),
|
||||
);
|
||||
```
|
||||
|
||||
You need to decide how you want to receive events from external sources like
|
||||
|
||||
- [via HTTP endpoint](https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md)
|
||||
- [via an AWS SQS queue](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md)
|
||||
|
||||
Further documentation:
|
||||
|
||||
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md>
|
||||
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md>
|
||||
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-bitbucket-cloud/README.md>
|
||||
|
||||
### Installation with Legacy Backend System
|
||||
|
||||
#### Installation without Events Support
|
||||
|
||||
And then add the entity provider to your catalog builder:
|
||||
|
||||
@@ -49,7 +77,7 @@ export default async function createPlugin(
|
||||
}
|
||||
```
|
||||
|
||||
### Installation with Events Support
|
||||
#### Installation with Events Support
|
||||
|
||||
Please follow the installation instructions at
|
||||
|
||||
@@ -83,6 +111,7 @@ export default async function createPlugin(
|
||||
env.config,
|
||||
{
|
||||
catalogApi: new CatalogClient({ discoveryApi: env.discovery }),
|
||||
events: env.events,
|
||||
logger: env.logger,
|
||||
scheduler: env.scheduler,
|
||||
tokenManager: env.tokenManager,
|
||||
|
||||
@@ -34,7 +34,7 @@ their own release cadence and versioning policy.
|
||||
|
||||
Release cadence: Monthly, specifically on the Tuesday before the third Wednesday of each month. The first release took place in March 2022.
|
||||
|
||||
The main release line in versioned with a major, minor and patch version but
|
||||
The main release line is versioned with a major, minor and patch version but
|
||||
does **not** adhere to [semver](https://semver.org). The version format is
|
||||
`<major>.<minor>.<patch>`, for example `1.3.0`.
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: OPA Permissions Wrapper
|
||||
author: Peter Macdonald
|
||||
authorUrl: https://github.com/Parsifal-M
|
||||
category: Authentication/Authorization
|
||||
description: Manage your Backstage permissions with OPA (Open Policy Agent)!
|
||||
documentation: https://github.com/Parsifal-M/backstage-opa-plugins/blob/main/plugins/permission-backend-module-opa-wrapper/README.md
|
||||
iconUrl: /img/opapermlogo.png
|
||||
npmPackageName: '@parsifal-m/plugin-permission-backend-module-opa-wrapper'
|
||||
addedDate: '2024-02-26'
|
||||
@@ -116,6 +116,7 @@
|
||||
"features/software-templates/input-examples",
|
||||
"features/software-templates/builtin-actions",
|
||||
"features/software-templates/writing-custom-actions",
|
||||
"features/software-templates/writing-tests-for-actions",
|
||||
"features/software-templates/writing-custom-field-extensions",
|
||||
"features/software-templates/writing-custom-step-layouts",
|
||||
"features/software-templates/authorizing-parameters-steps-and-actions",
|
||||
@@ -307,6 +308,7 @@
|
||||
"auth/gitlab/provider",
|
||||
"auth/google/provider",
|
||||
"auth/google/gcp-iap-auth",
|
||||
"auth/guest/provider",
|
||||
"auth/okta/provider",
|
||||
"auth/oauth2-proxy/provider",
|
||||
"auth/onelogin/provider",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 572 KiB |
@@ -161,6 +161,7 @@ nav:
|
||||
- GitLab: 'auth/gitlab/provider.md'
|
||||
- Google: 'auth/google/provider.md'
|
||||
- Google IAP: 'auth/google/gcp-iap-auth.md'
|
||||
- Guest: 'auth/guest/provider.md'
|
||||
- OAuth2Proxy: 'auth/oauth2-proxy/provider.md'
|
||||
- Okta: 'auth/okta/provider.md'
|
||||
- OneLogin: 'auth/onelogin/provider.md'
|
||||
|
||||
@@ -28,10 +28,12 @@ import {
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserPrincipal,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { tokenManagerServiceFactory } from '../tokenManager';
|
||||
|
||||
// TODO: Ship discovery mock service in the service factory tester
|
||||
const mockDeps = [
|
||||
discoveryServiceFactory(),
|
||||
tokenManagerServiceFactory,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ServerTokenManager, TokenManager } from '@backstage/backend-common';
|
||||
import { TokenManager } from '@backstage/backend-common';
|
||||
import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
@@ -27,10 +27,7 @@ import {
|
||||
createServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import {
|
||||
DefaultIdentityClient,
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { IdentityApiGetIdentityRequest } from '@backstage/plugin-auth-node';
|
||||
import { decodeJwt } from 'jose';
|
||||
|
||||
/** @internal */
|
||||
@@ -38,7 +35,6 @@ export type InternalBackstageCredentials<TPrincipal = unknown> =
|
||||
BackstageCredentials<TPrincipal> & {
|
||||
version: string;
|
||||
token?: string;
|
||||
authMethod: 'token' | 'cookie' | 'none';
|
||||
};
|
||||
|
||||
export function createCredentialsWithServicePrincipal(
|
||||
@@ -51,24 +47,23 @@ export function createCredentialsWithServicePrincipal(
|
||||
type: 'service',
|
||||
subject: sub,
|
||||
},
|
||||
authMethod: 'token',
|
||||
};
|
||||
}
|
||||
|
||||
export function createCredentialsWithUserPrincipal(
|
||||
sub: string,
|
||||
token: string,
|
||||
authMethod: 'token' | 'cookie' = 'token',
|
||||
expiresAt?: Date,
|
||||
): InternalBackstageCredentials<BackstageUserPrincipal> {
|
||||
return {
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
token,
|
||||
expiresAt,
|
||||
principal: {
|
||||
type: 'user',
|
||||
userEntityRef: sub,
|
||||
},
|
||||
authMethod,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +74,6 @@ export function createCredentialsWithNonePrincipal(): InternalBackstageCredentia
|
||||
principal: {
|
||||
type: 'none',
|
||||
},
|
||||
authMethod: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +108,7 @@ class DefaultAuthService implements AuthService {
|
||||
private readonly disableDefaultAuthPolicy: boolean,
|
||||
) {}
|
||||
|
||||
// allowLimitedAccess is currently ignored, since we currently always use the full user tokens
|
||||
async authenticate(token: string): Promise<BackstageCredentials> {
|
||||
const { sub, aud } = decodeJwt(token);
|
||||
|
||||
@@ -137,6 +132,7 @@ class DefaultAuthService implements AuthService {
|
||||
return createCredentialsWithUserPrincipal(
|
||||
identity.identity.userEntityRef,
|
||||
token,
|
||||
this.#getJwtExpiration(token),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +155,12 @@ class DefaultAuthService implements AuthService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getNoneCredentials(): Promise<
|
||||
BackstageCredentials<BackstageNonePrincipal>
|
||||
> {
|
||||
return createCredentialsWithNonePrincipal();
|
||||
}
|
||||
|
||||
async getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
> {
|
||||
@@ -196,6 +198,30 @@ class DefaultAuthService implements AuthService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
|
||||
const { token } = internalCredentials;
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError(
|
||||
'User credentials is unexpectedly missing token',
|
||||
);
|
||||
}
|
||||
|
||||
return { token, expiresAt: this.#getJwtExpiration(token) };
|
||||
}
|
||||
|
||||
#getJwtExpiration(token: string) {
|
||||
const { exp } = decodeJwt(token);
|
||||
if (!exp) {
|
||||
throw new AuthenticationError('User token is missing expiration');
|
||||
}
|
||||
return new Date(exp * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -204,14 +230,15 @@ export const authServiceFactory = createServiceFactory({
|
||||
deps: {
|
||||
config: coreServices.rootConfig,
|
||||
logger: coreServices.rootLogger,
|
||||
discovery: coreServices.discovery,
|
||||
plugin: coreServices.pluginMetadata,
|
||||
identity: coreServices.identity,
|
||||
// Re-using the token manager makes sure that we use the same generated keys for
|
||||
// development as plugins that have not yet been migrated. It's important that this
|
||||
// keeps working as long as there are plugins that have not been migrated to the
|
||||
// new auth services in the new backend system.
|
||||
tokenManager: coreServices.tokenManager,
|
||||
},
|
||||
createRootContext({ config, logger }) {
|
||||
return ServerTokenManager.fromConfig(config, { logger });
|
||||
},
|
||||
async factory({ discovery, config, plugin }, tokenManager) {
|
||||
const identity = DefaultIdentityClient.create({ discovery });
|
||||
async factory({ config, plugin, identity, tokenManager }) {
|
||||
const disableDefaultAuthPolicy = Boolean(
|
||||
config.getOptionalBoolean(
|
||||
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
|
||||
|
||||
+141
-61
@@ -18,6 +18,7 @@ import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageUserPrincipal,
|
||||
DiscoveryService,
|
||||
HttpAuthService,
|
||||
coreServices,
|
||||
@@ -26,11 +27,8 @@ import {
|
||||
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
|
||||
import { parse as parseCookie } from 'cookie';
|
||||
import { Request, Response } from 'express';
|
||||
import { decodeJwt } from 'jose';
|
||||
import {
|
||||
createCredentialsWithNonePrincipal,
|
||||
toInternalBackstageCredentials,
|
||||
} from '../auth/authServiceFactory';
|
||||
|
||||
const FIVE_MINUTES_MS = 5 * 60 * 1000;
|
||||
|
||||
const BACKSTAGE_AUTH_COOKIE = 'backstage-auth';
|
||||
|
||||
@@ -41,54 +39,78 @@ function getTokenFromRequest(req: Request) {
|
||||
const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i);
|
||||
const token = matches?.[1];
|
||||
if (token) {
|
||||
return { token, isCookie: false };
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCookieFromRequest(req: Request) {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const cookies = parseCookie(cookieHeader);
|
||||
const token = cookies[BACKSTAGE_AUTH_COOKIE];
|
||||
if (token) {
|
||||
return { token, isCookie: true };
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return { token: undefined, isCookie: false };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function willExpireSoon(expiresAt: Date) {
|
||||
return Date.now() + FIVE_MINUTES_MS > expiresAt.getTime();
|
||||
}
|
||||
|
||||
const credentialsSymbol = Symbol('backstage-credentials');
|
||||
const limitedCredentialsSymbol = Symbol('backstage-limited-credentials');
|
||||
|
||||
type RequestWithCredentials = Request & {
|
||||
[credentialsSymbol]?: Promise<BackstageCredentials>;
|
||||
[limitedCredentialsSymbol]?: Promise<BackstageCredentials>;
|
||||
};
|
||||
|
||||
class DefaultHttpAuthService implements HttpAuthService {
|
||||
readonly #auth: AuthService;
|
||||
readonly #discovery: DiscoveryService;
|
||||
readonly #pluginId: string;
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly discovery: DiscoveryService,
|
||||
private readonly pluginId: string,
|
||||
) {}
|
||||
auth: AuthService,
|
||||
discovery: DiscoveryService,
|
||||
pluginId: string,
|
||||
) {
|
||||
this.#auth = auth;
|
||||
this.#discovery = discovery;
|
||||
this.#pluginId = pluginId;
|
||||
}
|
||||
|
||||
async #extractCredentialsFromRequest(req: Request) {
|
||||
const { token, isCookie } = getTokenFromRequest(req);
|
||||
const token = getTokenFromRequest(req);
|
||||
if (!token) {
|
||||
return createCredentialsWithNonePrincipal();
|
||||
return await this.#auth.getNoneCredentials();
|
||||
}
|
||||
|
||||
const credentials = toInternalBackstageCredentials(
|
||||
await this.auth.authenticate(token),
|
||||
);
|
||||
if (isCookie) {
|
||||
if (credentials.principal.type !== 'user') {
|
||||
throw new AuthenticationError(
|
||||
'Refusing to authenticate non-user principal with cookie auth',
|
||||
);
|
||||
}
|
||||
credentials.authMethod = 'cookie';
|
||||
return await this.#auth.authenticate(token);
|
||||
}
|
||||
|
||||
async #extractLimitedCredentialsFromRequest(req: Request) {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (token) {
|
||||
return await this.#auth.authenticate(token, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
return credentials;
|
||||
const cookie = getCookieFromRequest(req);
|
||||
if (cookie) {
|
||||
return await this.#auth.authenticate(cookie, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
return await this.#auth.getNoneCredentials();
|
||||
}
|
||||
|
||||
async #getCredentials(req: RequestWithCredentials) {
|
||||
@@ -96,73 +118,131 @@ class DefaultHttpAuthService implements HttpAuthService {
|
||||
this.#extractCredentialsFromRequest(req));
|
||||
}
|
||||
|
||||
async #getLimitedCredentials(req: RequestWithCredentials) {
|
||||
return (req[limitedCredentialsSymbol] ??=
|
||||
this.#extractLimitedCredentialsFromRequest(req));
|
||||
}
|
||||
|
||||
async credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
|
||||
req: Request,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
|
||||
const credentials = toInternalBackstageCredentials(
|
||||
await this.#getCredentials(req),
|
||||
);
|
||||
// Limited and full credentials are treated as two separate cases, this lets
|
||||
// us avoid internal dependencies between the AuthService and
|
||||
// HttpAuthService implementations
|
||||
const credentials = options?.allowLimitedAccess
|
||||
? await this.#getLimitedCredentials(req)
|
||||
: await this.#getCredentials(req);
|
||||
|
||||
const allowedPrincipalTypes = options?.allow;
|
||||
const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> =
|
||||
options?.allowedAuthMethods ?? ['token'];
|
||||
|
||||
if (
|
||||
credentials.authMethod !== 'none' &&
|
||||
!allowedAuthMethods.includes(credentials.authMethod)
|
||||
) {
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow the '${credentials.authMethod}' auth method`,
|
||||
);
|
||||
const allowed = options?.allow;
|
||||
if (!allowed) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
if (
|
||||
allowedPrincipalTypes &&
|
||||
!allowedPrincipalTypes.includes(credentials.principal.type as TAllowed)
|
||||
) {
|
||||
if (credentials.authMethod === 'none') {
|
||||
throw new AuthenticationError();
|
||||
if (this.#auth.isPrincipal(credentials, 'none')) {
|
||||
if (allowed.includes('none' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Missing credentials');
|
||||
} else if (this.#auth.isPrincipal(credentials, 'user')) {
|
||||
if (allowed.includes('user' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow '${credentials.principal.type}' credentials`,
|
||||
`This endpoint does not allow 'user' credentials`,
|
||||
);
|
||||
} else if (this.#auth.isPrincipal(credentials, 'service')) {
|
||||
if (allowed.includes('service' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow 'service' credentials`,
|
||||
);
|
||||
}
|
||||
|
||||
return credentials as any;
|
||||
throw new NotAllowedError(
|
||||
'Unknown principal type, this should never happen',
|
||||
);
|
||||
}
|
||||
|
||||
async issueUserCookie(res: Response): Promise<void> {
|
||||
const credentials = await this.credentials(res.req, { allow: ['user'] });
|
||||
async issueUserCookie(
|
||||
res: Response,
|
||||
options?: { credentials?: BackstageCredentials },
|
||||
): Promise<{ expiresAt: Date }> {
|
||||
if (res.headersSent) {
|
||||
throw new Error('Failed to issue user cookie, headers were already sent');
|
||||
}
|
||||
|
||||
let credentials: BackstageCredentials<BackstageUserPrincipal>;
|
||||
if (options?.credentials) {
|
||||
if (!this.#auth.isPrincipal(options.credentials, 'user')) {
|
||||
throw new AuthenticationError(
|
||||
'Refused to issue cookie for non-user principal',
|
||||
);
|
||||
}
|
||||
credentials = options.credentials;
|
||||
} else {
|
||||
credentials = await this.credentials(res.req, { allow: ['user'] });
|
||||
}
|
||||
|
||||
const existingExpiresAt = await this.#existingCookieExpiration(res.req);
|
||||
if (existingExpiresAt && !willExpireSoon(existingExpiresAt)) {
|
||||
return { expiresAt: existingExpiresAt };
|
||||
}
|
||||
|
||||
const originHeader = res.req.headers.origin;
|
||||
const origin =
|
||||
!originHeader || originHeader === 'null' ? undefined : originHeader;
|
||||
|
||||
// https://backstage.example.com/api/catalog
|
||||
const externalBaseUrlStr = await this.discovery.getExternalBaseUrl(
|
||||
this.pluginId,
|
||||
const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl(
|
||||
this.#pluginId,
|
||||
);
|
||||
const externalBaseUrl = new URL(externalBaseUrlStr);
|
||||
const externalBaseUrl = new URL(origin ?? externalBaseUrlStr);
|
||||
|
||||
const { token } = toInternalBackstageCredentials(credentials);
|
||||
const { token, expiresAt } = await this.#auth.getLimitedUserToken(
|
||||
credentials,
|
||||
);
|
||||
if (!token) {
|
||||
throw new Error('User credentials is unexpectedly missing token');
|
||||
}
|
||||
|
||||
// TODO: Proper refresh and expiration handling
|
||||
const expires = decodeJwt(token).exp!;
|
||||
const secure =
|
||||
externalBaseUrl.protocol === 'https:' ||
|
||||
externalBaseUrl.hostname === 'localhost';
|
||||
|
||||
// TODO: refresh this thing
|
||||
res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
|
||||
domain: externalBaseUrl.hostname,
|
||||
httpOnly: true,
|
||||
expires: new Date(expires * 1000),
|
||||
path: externalBaseUrl.pathname,
|
||||
expires: expiresAt,
|
||||
secure,
|
||||
priority: 'high',
|
||||
sameSite: 'lax', // TBD
|
||||
sameSite: secure ? 'none' : 'lax',
|
||||
});
|
||||
|
||||
throw new Error('Method not implemented.');
|
||||
return { expiresAt };
|
||||
}
|
||||
|
||||
async #existingCookieExpiration(req: Request): Promise<Date | undefined> {
|
||||
const existingCookie = getCookieFromRequest(req);
|
||||
if (!existingCookie) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const existingCredentials = await this.#auth.authenticate(existingCookie, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
if (!this.#auth.isPrincipal(existingCredentials, 'user')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return existingCredentials.expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-3
@@ -53,7 +53,10 @@ describe('createCredentialsBarrier', () => {
|
||||
.expect(401)
|
||||
.expect(res =>
|
||||
expect(res.body).toMatchObject({
|
||||
error: { name: 'AuthenticationError', message: '' },
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: 'Missing credentials',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -98,7 +101,7 @@ describe('createCredentialsBarrier', () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should allow exceptions to the default auth policy to be made', async () => {
|
||||
it('should allow exceptions for unauthenticated access', async () => {
|
||||
const { app, barrier } = setup();
|
||||
|
||||
await request(app).get('/').send().expect(401);
|
||||
@@ -118,5 +121,55 @@ describe('createCredentialsBarrier', () => {
|
||||
await request(app).get('/other').send().expect(200);
|
||||
});
|
||||
|
||||
// TODO: cookie auth
|
||||
it('should allow exceptions for cookie access', async () => {
|
||||
const { app, barrier } = setup();
|
||||
|
||||
await request(app).get('/').send().expect(401);
|
||||
await request(app).get('/public').send().expect(401);
|
||||
await request(app).get('/other').send().expect(401);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('cookie', mockCredentials.limitedUser.cookie())
|
||||
.send()
|
||||
.expect(401);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
barrier.addAuthPolicy({ allow: 'user-cookie', path: '/static' });
|
||||
|
||||
await request(app).get('/').send().expect(401);
|
||||
await request(app).get('/static').send().expect(401);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('cookie', mockCredentials.limitedUser.cookie())
|
||||
.send()
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
await request(app).get('/other').send().expect(401);
|
||||
|
||||
// Unauthenticated access should take precedence
|
||||
barrier.addAuthPolicy({ allow: 'unauthenticated', path: '/' });
|
||||
|
||||
await request(app).get('/').send().expect(200);
|
||||
await request(app).get('/static').send().expect(200);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('cookie', mockCredentials.limitedUser.cookie())
|
||||
.send()
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('cookie', mockCredentials.limitedUser.invalidCookie())
|
||||
.send()
|
||||
.expect(200);
|
||||
await request(app).get('/other').send().expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export function createCredentialsBarrier(options: {
|
||||
httpAuth
|
||||
.credentials(req, {
|
||||
allow: ['user', 'service'],
|
||||
allowedAuthMethods: allowsCookie ? ['token', 'cookie'] : ['token'],
|
||||
allowLimitedAccess: allowsCookie,
|
||||
})
|
||||
.then(
|
||||
() => next(),
|
||||
|
||||
+5
-2
@@ -43,8 +43,11 @@ export class DefaultUserInfoService implements UserInfoService {
|
||||
if (typeof userEntityRef !== 'string') {
|
||||
throw new Error('User entity ref must be a string');
|
||||
}
|
||||
if (!Array.isArray(ownershipEntityRefs)) {
|
||||
throw new Error('Ownership entity refs must be an array');
|
||||
if (
|
||||
!Array.isArray(ownershipEntityRefs) ||
|
||||
ownershipEntityRefs.some(ref => typeof ref !== 'string')
|
||||
) {
|
||||
throw new Error('Ownership entity refs must be an array of strings');
|
||||
}
|
||||
|
||||
return { userEntityRef, ownershipEntityRefs };
|
||||
|
||||
@@ -67,6 +67,7 @@ import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
import { TokenManagerService as TokenManager } from '@backstage/backend-plugin-api';
|
||||
import { TransportStreamOptions } from 'winston-transport';
|
||||
import { UrlReaderService as UrlReader } from '@backstage/backend-plugin-api';
|
||||
import { UserInfoService } from '@backstage/backend-plugin-api';
|
||||
import { V1PodTemplateSpec } from '@kubernetes/client-node';
|
||||
import * as winston from 'winston';
|
||||
import { Writable } from 'stream';
|
||||
@@ -239,30 +240,32 @@ export function createLegacyAuthAdapters<
|
||||
TOptions extends {
|
||||
auth?: AuthService;
|
||||
httpAuth?: HttpAuthService;
|
||||
userInfo?: UserInfoService;
|
||||
identity?: IdentityService;
|
||||
tokenManager?: TokenManager;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
},
|
||||
TAdapters = TOptions extends {
|
||||
TAdapters = (TOptions extends {
|
||||
auth?: AuthService;
|
||||
}
|
||||
? TOptions extends {
|
||||
httpAuth?: HttpAuthService;
|
||||
? {
|
||||
auth: AuthService;
|
||||
}
|
||||
: {}) &
|
||||
(TOptions extends {
|
||||
httpAuth?: HttpAuthService;
|
||||
}
|
||||
? {
|
||||
auth: AuthService;
|
||||
httpAuth: HttpAuthService;
|
||||
}
|
||||
: {
|
||||
auth: AuthService;
|
||||
: {}) &
|
||||
(TOptions extends {
|
||||
userInfo?: UserInfoService;
|
||||
}
|
||||
? {
|
||||
userInfo: UserInfoService;
|
||||
}
|
||||
: TOptions extends {
|
||||
httpAuth?: HttpAuthService;
|
||||
}
|
||||
? {
|
||||
httpAuth: HttpAuthService;
|
||||
}
|
||||
: 'error: at least one of auth and/or httpAuth must be provided',
|
||||
: {}),
|
||||
>(options: TOptions): TAdapters;
|
||||
|
||||
// @public
|
||||
|
||||
@@ -101,10 +101,10 @@
|
||||
"pg": "^8.11.3",
|
||||
"raw-body": "^2.4.1",
|
||||
"tar": "^6.1.12",
|
||||
"uuid": "^8.3.2",
|
||||
"uuid": "^9.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"winston-transport": "^4.5.0",
|
||||
"yauzl": "^2.10.0",
|
||||
"yauzl": "^3.0.0",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -56,7 +56,22 @@ describe('createLegacyAuthAdapters', () => {
|
||||
expect(ret.httpAuth).toBe(httpAuth);
|
||||
});
|
||||
|
||||
it('should adapt both auth and httpAuth if neither are provided', () => {
|
||||
it('should pass through userInfo if it is provided', () => {
|
||||
const auth = {};
|
||||
const userInfo = {};
|
||||
const ret = createLegacyAuthAdapters({
|
||||
auth: auth as any,
|
||||
userInfo: userInfo as any,
|
||||
tokenManager: mockServices.tokenManager(),
|
||||
discovery: {} as any,
|
||||
identity: mockServices.identity(),
|
||||
});
|
||||
|
||||
expect(ret.auth).toBe(auth);
|
||||
expect(ret.userInfo).toBe(userInfo);
|
||||
});
|
||||
|
||||
it('should adapt all services if none are provided', () => {
|
||||
const ret = createLegacyAuthAdapters({
|
||||
auth: undefined,
|
||||
httpAuth: undefined,
|
||||
@@ -68,6 +83,7 @@ describe('createLegacyAuthAdapters', () => {
|
||||
expect(ret).toEqual({
|
||||
auth: expect.any(Object),
|
||||
httpAuth: expect.any(Object),
|
||||
userInfo: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
BackstageNonePrincipal,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserInfo,
|
||||
BackstageUserPrincipal,
|
||||
HttpAuthService,
|
||||
IdentityService,
|
||||
TokenManagerService,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { ServerTokenManager, TokenManager } from '../tokens';
|
||||
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
|
||||
@@ -63,6 +66,12 @@ class AuthCompat implements AuthService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getNoneCredentials(): Promise<
|
||||
BackstageCredentials<BackstageNonePrincipal>
|
||||
> {
|
||||
return createCredentialsWithNonePrincipal();
|
||||
}
|
||||
|
||||
async getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
> {
|
||||
@@ -87,6 +96,7 @@ class AuthCompat implements AuthService {
|
||||
return createCredentialsWithUserPrincipal(
|
||||
identity.identity.userEntityRef,
|
||||
token,
|
||||
this.#getJwtExpiration(token),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +131,30 @@ class AuthCompat implements AuthService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
|
||||
const { token } = internalCredentials;
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError(
|
||||
'User credentials is unexpectedly missing token',
|
||||
);
|
||||
}
|
||||
|
||||
return { token, expiresAt: this.#getJwtExpiration(token) };
|
||||
}
|
||||
|
||||
#getJwtExpiration(token: string) {
|
||||
const { exp } = decodeJwt(token);
|
||||
if (!exp) {
|
||||
throw new AuthenticationError('User token is missing expiration');
|
||||
}
|
||||
return new Date(exp * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromRequest(req: Request) {
|
||||
@@ -144,7 +178,11 @@ type RequestWithCredentials = Request & {
|
||||
};
|
||||
|
||||
class HttpAuthCompat implements HttpAuthService {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
#auth: AuthService;
|
||||
|
||||
constructor(auth: AuthService) {
|
||||
this.#auth = auth;
|
||||
}
|
||||
|
||||
async #extractCredentialsFromRequest(req: Request) {
|
||||
const token = getTokenFromRequest(req);
|
||||
@@ -153,7 +191,7 @@ class HttpAuthCompat implements HttpAuthService {
|
||||
}
|
||||
|
||||
const credentials = toInternalBackstageCredentials(
|
||||
await this.auth.authenticate(token),
|
||||
await this.#auth.authenticate(token),
|
||||
);
|
||||
|
||||
return credentials;
|
||||
@@ -168,39 +206,79 @@ class HttpAuthCompat implements HttpAuthService {
|
||||
req: Request,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
|
||||
const credentials = toInternalBackstageCredentials(
|
||||
await this.#getCredentials(req),
|
||||
);
|
||||
|
||||
const allowedPrincipalTypes = options?.allow;
|
||||
const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> =
|
||||
options?.allowedAuthMethods ?? ['token'];
|
||||
const allowed = options?.allow;
|
||||
if (!allowed) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
if (this.#auth.isPrincipal(credentials, 'none')) {
|
||||
if (allowed.includes('none' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Missing credentials');
|
||||
} else if (this.#auth.isPrincipal(credentials, 'user')) {
|
||||
if (allowed.includes('user' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
if (
|
||||
credentials.authMethod !== 'none' &&
|
||||
!allowedAuthMethods.includes(credentials.authMethod)
|
||||
) {
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow the '${credentials.authMethod}' auth method`,
|
||||
`This endpoint does not allow 'user' credentials`,
|
||||
);
|
||||
} else if (this.#auth.isPrincipal(credentials, 'service')) {
|
||||
if (allowed.includes('service' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow 'service' credentials`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
allowedPrincipalTypes &&
|
||||
!allowedPrincipalTypes.includes(credentials.principal.type as TAllowed)
|
||||
) {
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow '${credentials.principal.type}' credentials`,
|
||||
);
|
||||
}
|
||||
|
||||
return credentials as any;
|
||||
throw new NotAllowedError(
|
||||
'Unknown principal type, this should never happen',
|
||||
);
|
||||
}
|
||||
|
||||
async issueUserCookie(_res: Response): Promise<void> {}
|
||||
async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> {
|
||||
return { expiresAt: new Date(Date.now() + 3600_000) };
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInfoCompat implements UserInfoService {
|
||||
async getUserInfo(
|
||||
credentials: BackstageCredentials,
|
||||
): Promise<BackstageUserInfo> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
if (internalCredentials.principal.type !== 'user') {
|
||||
throw new Error('Only user credentials are supported');
|
||||
}
|
||||
if (!internalCredentials.token) {
|
||||
throw new Error('User credentials is unexpectedly missing token');
|
||||
}
|
||||
const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = decodeJwt(
|
||||
internalCredentials.token,
|
||||
);
|
||||
|
||||
if (typeof userEntityRef !== 'string') {
|
||||
throw new Error('User entity ref must be a string');
|
||||
}
|
||||
if (
|
||||
!Array.isArray(ownershipEntityRefs) ||
|
||||
ownershipEntityRefs.some(ref => typeof ref !== 'string')
|
||||
) {
|
||||
throw new Error('Ownership entity refs must be an array of strings');
|
||||
}
|
||||
|
||||
return { userEntityRef, ownershipEntityRefs };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,38 +289,47 @@ export function createLegacyAuthAdapters<
|
||||
TOptions extends {
|
||||
auth?: AuthService;
|
||||
httpAuth?: HttpAuthService;
|
||||
userInfo?: UserInfoService;
|
||||
identity?: IdentityService;
|
||||
tokenManager?: TokenManager;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
},
|
||||
TAdapters = TOptions extends {
|
||||
auth?: AuthService;
|
||||
}
|
||||
? TOptions extends { httpAuth?: HttpAuthService }
|
||||
? { auth: AuthService; httpAuth: HttpAuthService }
|
||||
: { auth: AuthService }
|
||||
: TOptions extends { httpAuth?: HttpAuthService }
|
||||
? { httpAuth: HttpAuthService }
|
||||
: 'error: at least one of auth and/or httpAuth must be provided',
|
||||
TAdapters = (TOptions extends { auth?: AuthService }
|
||||
? { auth: AuthService }
|
||||
: {}) &
|
||||
(TOptions extends { httpAuth?: HttpAuthService }
|
||||
? { httpAuth: HttpAuthService }
|
||||
: {}) &
|
||||
(TOptions extends { userInfo?: UserInfoService }
|
||||
? { userInfo: UserInfoService }
|
||||
: {}),
|
||||
>(options: TOptions): TAdapters {
|
||||
const { auth, httpAuth, discovery } = options;
|
||||
const {
|
||||
auth,
|
||||
httpAuth,
|
||||
userInfo = new UserInfoCompat(),
|
||||
discovery,
|
||||
} = options;
|
||||
|
||||
if (auth && httpAuth) {
|
||||
return {
|
||||
auth,
|
||||
httpAuth,
|
||||
userInfo,
|
||||
} as TAdapters;
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
return {
|
||||
auth,
|
||||
userInfo,
|
||||
} as TAdapters;
|
||||
}
|
||||
|
||||
if (httpAuth) {
|
||||
return {
|
||||
httpAuth,
|
||||
userInfo,
|
||||
} as TAdapters;
|
||||
}
|
||||
|
||||
@@ -257,5 +344,6 @@ export function createLegacyAuthAdapters<
|
||||
return {
|
||||
auth: authImpl,
|
||||
httpAuth: httpAuthImpl,
|
||||
userInfo,
|
||||
} as TAdapters;
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
NotModifiedError,
|
||||
ResponseError,
|
||||
} from '@backstage/errors';
|
||||
import express from 'express';
|
||||
import createError from 'http-errors';
|
||||
import request from 'supertest';
|
||||
import { errorHandler } from './errorHandler';
|
||||
import { STATUS_CODES } from 'http';
|
||||
|
||||
describe('errorHandler', () => {
|
||||
it('gives default code and message', async () => {
|
||||
@@ -116,6 +118,53 @@ describe('errorHandler', () => {
|
||||
app.use('/ConflictError', () => {
|
||||
throw new ConflictError();
|
||||
});
|
||||
app.use('/ResponseErrorBackstagePlugin', async (_req, _res, next) => {
|
||||
const mockedResponse = {
|
||||
status: jest.fn(() => mockedResponse),
|
||||
json: jest.fn(() => mockedResponse),
|
||||
} as unknown as jest.Mocked<express.Response>;
|
||||
|
||||
// serialize AuthenticationError in mockedResponse
|
||||
errorHandler()(
|
||||
new AuthenticationError('an error'),
|
||||
{ method: 'GET', url: '' } as express.Request,
|
||||
mockedResponse,
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
const status = mockedResponse.status.mock.calls[0][0];
|
||||
next(
|
||||
await ResponseError.fromResponse({
|
||||
headers: new Headers({
|
||||
'content-type': 'application/json',
|
||||
}),
|
||||
ok: false,
|
||||
redirected: false,
|
||||
status,
|
||||
statusText: STATUS_CODES[status]!,
|
||||
type: 'default',
|
||||
url: '',
|
||||
text: async () =>
|
||||
JSON.stringify(mockedResponse.json.mock.calls[0][0]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
app.use('/ResponseError', async (_req, _res, next) => {
|
||||
next(
|
||||
await ResponseError.fromResponse({
|
||||
headers: new Headers({
|
||||
'content-type': 'application/json',
|
||||
}),
|
||||
ok: false,
|
||||
redirected: false,
|
||||
status: 403,
|
||||
statusText: STATUS_CODES[403]!,
|
||||
type: 'default',
|
||||
url: '',
|
||||
text: async () => JSON.stringify({}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
app.use(errorHandler());
|
||||
|
||||
const r = request(app);
|
||||
@@ -138,6 +187,14 @@ describe('errorHandler', () => {
|
||||
expect((await r.get('/ConflictError')).body.error.name).toBe(
|
||||
'ConflictError',
|
||||
);
|
||||
expect((await r.get('/ResponseErrorBackstagePlugin')).status).toBe(401);
|
||||
expect((await r.get('/ResponseErrorBackstagePlugin')).body.error.name).toBe(
|
||||
'ResponseError',
|
||||
);
|
||||
expect((await r.get('/ResponseError')).status).toBe(403);
|
||||
expect((await r.get('/ResponseError')).body.error.name).toBe(
|
||||
'ResponseError',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs all 500 errors', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Config } from '@backstage/config';
|
||||
import { ConfigSchema } from '@backstage/config-loader';
|
||||
import { EventBroker } from '@backstage/plugin-events-node';
|
||||
import { EventsBackend } from '@backstage/plugin-events-backend';
|
||||
import { EventsService } from '@backstage/plugin-events-node';
|
||||
import { FeatureDiscoveryService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { HttpPostIngressOptions } from '@backstage/plugin-events-node';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
@@ -215,6 +216,7 @@ export type LegacyPluginEnvironment = {
|
||||
scheduler: PluginTaskScheduler;
|
||||
identity: IdentityApi;
|
||||
eventBroker: EventBroker;
|
||||
events: EventsService;
|
||||
pluginProvider: BackendPluginProvider;
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
EventBroker,
|
||||
EventsService,
|
||||
HttpPostIngressOptions,
|
||||
} from '@backstage/plugin-events-node';
|
||||
|
||||
@@ -64,6 +65,7 @@ export type LegacyPluginEnvironment = {
|
||||
scheduler: PluginTaskScheduler;
|
||||
identity: IdentityApi;
|
||||
eventBroker: EventBroker;
|
||||
events: EventsService;
|
||||
pluginProvider: BackendPluginProvider;
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@backstage/plugin-app-backend": "workspace:^",
|
||||
"@backstage/plugin-auth-backend": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-guest-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-azure-devops-backend": "workspace:^",
|
||||
"@backstage/plugin-badges-backend": "workspace:^",
|
||||
|
||||
@@ -20,6 +20,7 @@ const backend = createBackend();
|
||||
|
||||
backend.add(import('@backstage/plugin-auth-backend'));
|
||||
backend.add(import('./authModuleGithubProvider'));
|
||||
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
|
||||
|
||||
backend.add(import('@backstage/plugin-adr-backend'));
|
||||
backend.add(import('@backstage/plugin-app-backend/alpha'));
|
||||
|
||||
@@ -24,7 +24,21 @@ import { Response as Response_2 } from 'express';
|
||||
// @public (undocumented)
|
||||
export interface AuthService {
|
||||
// (undocumented)
|
||||
authenticate(token: string): Promise<BackstageCredentials>;
|
||||
authenticate(
|
||||
token: string,
|
||||
options?: {
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials>;
|
||||
// (undocumented)
|
||||
getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}>;
|
||||
// (undocumented)
|
||||
getNoneCredentials(): Promise<BackstageCredentials<BackstageNonePrincipal>>;
|
||||
// (undocumented)
|
||||
getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
@@ -107,6 +121,7 @@ export interface BackendPluginRegistrationPoints {
|
||||
// @public (undocumented)
|
||||
export type BackstageCredentials<TPrincipal = unknown> = {
|
||||
$$type: '@backstage/BackstageCredentials';
|
||||
expiresAt?: Date;
|
||||
principal: TPrincipal;
|
||||
};
|
||||
|
||||
@@ -299,11 +314,18 @@ export interface HttpAuthService {
|
||||
req: Request_2<any, any, any, any, any>,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
|
||||
// (undocumented)
|
||||
issueUserCookie(res: Response_2): Promise<void>;
|
||||
issueUserCookie(
|
||||
res: Response_2,
|
||||
options?: {
|
||||
credentials?: BackstageCredentials<BackstageUserPrincipal>;
|
||||
},
|
||||
): Promise<{
|
||||
expiresAt: Date;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -46,6 +46,8 @@ export type BackstageServicePrincipal = {
|
||||
export type BackstageCredentials<TPrincipal = unknown> = {
|
||||
$$type: '@backstage/BackstageCredentials';
|
||||
|
||||
expiresAt?: Date;
|
||||
|
||||
principal: TPrincipal;
|
||||
};
|
||||
|
||||
@@ -63,13 +65,20 @@ export type BackstagePrincipalTypes = {
|
||||
* @public
|
||||
*/
|
||||
export interface AuthService {
|
||||
authenticate(token: string): Promise<BackstageCredentials>;
|
||||
authenticate(
|
||||
token: string,
|
||||
options?: {
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials>;
|
||||
|
||||
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
|
||||
credentials: BackstageCredentials,
|
||||
type: TType,
|
||||
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
|
||||
|
||||
getNoneCredentials(): Promise<BackstageCredentials<BackstageNonePrincipal>>;
|
||||
|
||||
getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
>;
|
||||
@@ -78,4 +87,8 @@ export interface AuthService {
|
||||
onBehalfOf: BackstageCredentials;
|
||||
targetPluginId: string;
|
||||
}): Promise<{ token: string }>;
|
||||
|
||||
getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }>;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService';
|
||||
import {
|
||||
BackstageCredentials,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageUserPrincipal,
|
||||
} from './AuthService';
|
||||
|
||||
/** @public */
|
||||
export interface HttpAuthService {
|
||||
@@ -23,9 +27,14 @@ export interface HttpAuthService {
|
||||
req: Request<any, any, any, any, any>,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
|
||||
|
||||
issueUserCookie(res: Response): Promise<void>;
|
||||
issueUserCookie(
|
||||
res: Response,
|
||||
options?: {
|
||||
credentials?: BackstageCredentials<BackstageUserPrincipal>;
|
||||
},
|
||||
): Promise<{ expiresAt: Date }>;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"knex": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.0",
|
||||
"uuid": "^8.0.0",
|
||||
"uuid": "^9.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { BackstageCredentials } from '@backstage/backend-plugin-api';
|
||||
import { BackstageNonePrincipal } from '@backstage/backend-plugin-api';
|
||||
import { BackstageServicePrincipal } from '@backstage/backend-plugin-api';
|
||||
import { BackstageUserInfo } from '@backstage/backend-plugin-api';
|
||||
import { BackstageUserPrincipal } from '@backstage/backend-plugin-api';
|
||||
import { CacheService } from '@backstage/backend-plugin-api';
|
||||
import { DatabaseService } from '@backstage/backend-plugin-api';
|
||||
@@ -37,6 +38,7 @@ import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
import { TokenManagerService } from '@backstage/backend-plugin-api';
|
||||
import { UrlReaderService } from '@backstage/backend-plugin-api';
|
||||
import { UserInfoService } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public
|
||||
export function createMockDirectory(
|
||||
@@ -48,6 +50,17 @@ export function isDockerDisabledForTests(): boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
export namespace mockCredentials {
|
||||
export function limitedUser(
|
||||
userEntityRef?: string,
|
||||
): BackstageCredentials<BackstageUserPrincipal>;
|
||||
export namespace limitedUser {
|
||||
export function cookie(userEntityRef?: string): string;
|
||||
// (undocumented)
|
||||
export function invalidCookie(): string;
|
||||
// (undocumented)
|
||||
export function invalidToken(): string;
|
||||
export function token(userEntityRef?: string): string;
|
||||
}
|
||||
export function none(): BackstageCredentials<BackstageNonePrincipal>;
|
||||
export namespace none {
|
||||
export function header(): string;
|
||||
@@ -316,6 +329,17 @@ export namespace mockServices {
|
||||
partialImpl?: Partial<UrlReaderService> | undefined,
|
||||
) => ServiceMock<UrlReaderService>;
|
||||
}
|
||||
export function userInfo(
|
||||
customInfo?: Partial<BackstageUserInfo>,
|
||||
): UserInfoService;
|
||||
// (undocumented)
|
||||
export namespace userInfo {
|
||||
const factory: () => ServiceFactory<UserInfoService, 'plugin'>;
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<UserInfoService> | undefined,
|
||||
) => ServiceMock<UserInfoService>;
|
||||
}
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
{
|
||||
"name": "@backstage/backend-test-utils",
|
||||
"description": "Test helpers library for Backstage backends",
|
||||
"version": "0.3.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"description": "Test helpers library for Backstage backends",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage",
|
||||
"test"
|
||||
],
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/backend-test-utils"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"package.json": [
|
||||
@@ -18,28 +32,17 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/backend-test-utils"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage",
|
||||
"test"
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean",
|
||||
"start": "backstage-cli package start"
|
||||
"start": "backstage-cli package start",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-app-api": "workspace:^",
|
||||
@@ -50,25 +53,23 @@
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"better-sqlite3": "^9.0.0",
|
||||
"cookie": "^0.6.0",
|
||||
"express": "^4.17.1",
|
||||
"fs-extra": "^11.0.0",
|
||||
"knex": "^3.0.0",
|
||||
"msw": "^1.0.0",
|
||||
"mysql2": "^3.0.0",
|
||||
"pg": "^8.11.3",
|
||||
"testcontainers": "^8.1.2",
|
||||
"testcontainers": "^10.0.0",
|
||||
"textextensions": "^5.16.0",
|
||||
"uuid": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/jest": "*"
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
"peerDependencies": {
|
||||
"@types/jest": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function startMysqlContainer(image: string) {
|
||||
|
||||
const container = await new GenericContainer(image)
|
||||
.withExposedPorts(3306)
|
||||
.withEnv('MYSQL_ROOT_PASSWORD', password)
|
||||
.withEnvironment({ MYSQL_ROOT_PASSWORD: password })
|
||||
.withTmpFs({ '/var/lib/mysql': 'rw' })
|
||||
.start();
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function startPostgresContainer(image: string) {
|
||||
|
||||
const container = await new GenericContainer(image)
|
||||
.withExposedPorts(5432)
|
||||
.withEnv('POSTGRES_PASSWORD', password)
|
||||
.withEnvironment({ POSTGRES_PASSWORD: password })
|
||||
.withTmpFs({ '/var/lib/postgresql/data': 'rw' })
|
||||
.start();
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@ describe('MockAuthService', () => {
|
||||
auth.authenticate(mockCredentials.user.token()),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.user.token(), {
|
||||
allowLimitedAccess: true,
|
||||
}),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.user.token()),
|
||||
).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF));
|
||||
@@ -66,6 +72,44 @@ describe('MockAuthService', () => {
|
||||
).rejects.toThrow('User token is invalid');
|
||||
});
|
||||
|
||||
it('should authenticate mock limited user tokens', async () => {
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token()),
|
||||
).rejects.toThrow('Limited user token is not allowed');
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token(), {}),
|
||||
).rejects.toThrow('Limited user token is not allowed');
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token(), {
|
||||
allowLimitedAccess: false,
|
||||
}),
|
||||
).rejects.toThrow('Limited user token is not allowed');
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token(), {
|
||||
allowLimitedAccess: true,
|
||||
}),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token(), {
|
||||
allowLimitedAccess: true,
|
||||
}),
|
||||
).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF));
|
||||
|
||||
await expect(
|
||||
auth.authenticate(
|
||||
mockCredentials.limitedUser.token('user:default/other'),
|
||||
{
|
||||
allowLimitedAccess: true,
|
||||
},
|
||||
),
|
||||
).resolves.toEqual(mockCredentials.user('user:default/other'));
|
||||
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.invalidToken()),
|
||||
).rejects.toThrow('Limited user token is invalid');
|
||||
});
|
||||
|
||||
it('should authenticate mock service tokens', async () => {
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.service.token()),
|
||||
@@ -113,6 +157,12 @@ describe('MockAuthService', () => {
|
||||
).rejects.toThrow('Service token is invalid');
|
||||
});
|
||||
|
||||
it('should return none credentials', async () => {
|
||||
await expect(auth.getNoneCredentials()).resolves.toEqual(
|
||||
mockCredentials.none(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return own service credentials', async () => {
|
||||
await expect(auth.getOwnServiceCredentials()).resolves.toEqual(
|
||||
mockCredentials.service('plugin:test'),
|
||||
@@ -211,4 +261,32 @@ describe('MockAuthService', () => {
|
||||
`Refused to issue service token for credential type 'none'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should issue limited user tokens', async () => {
|
||||
await expect(
|
||||
auth.getLimitedUserToken(mockCredentials.user()),
|
||||
).resolves.toEqual({
|
||||
token: mockCredentials.limitedUser.token(),
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
await expect(
|
||||
auth.getLimitedUserToken(mockCredentials.user('user:default/other')),
|
||||
).resolves.toEqual({
|
||||
token: mockCredentials.limitedUser.token('user:default/other'),
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
await expect(
|
||||
auth.getLimitedUserToken(mockCredentials.none() as any),
|
||||
).rejects.toThrow(
|
||||
"Refused to issue limited user token for credential type 'none'",
|
||||
);
|
||||
|
||||
await expect(
|
||||
auth.getLimitedUserToken(mockCredentials.service() as any),
|
||||
).rejects.toThrow(
|
||||
"Refused to issue limited user token for credential type 'service'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,11 @@ import {
|
||||
mockCredentials,
|
||||
MOCK_USER_TOKEN,
|
||||
MOCK_USER_TOKEN_PREFIX,
|
||||
MOCK_INVALID_USER_TOKEN,
|
||||
MOCK_USER_LIMITED_TOKEN_PREFIX,
|
||||
MOCK_INVALID_USER_LIMITED_TOKEN,
|
||||
MOCK_SERVICE_TOKEN,
|
||||
MOCK_SERVICE_TOKEN_PREFIX,
|
||||
MOCK_INVALID_USER_TOKEN,
|
||||
MOCK_INVALID_SERVICE_TOKEN,
|
||||
UserTokenPayload,
|
||||
ServiceTokenPayload,
|
||||
@@ -48,7 +50,10 @@ export class MockAuthService implements AuthService {
|
||||
this.disableDefaultAuthPolicy = options.disableDefaultAuthPolicy;
|
||||
}
|
||||
|
||||
async authenticate(token: string): Promise<BackstageCredentials> {
|
||||
async authenticate(
|
||||
token: string,
|
||||
options?: { allowLimitedAccess?: boolean },
|
||||
): Promise<BackstageCredentials> {
|
||||
switch (token) {
|
||||
case MOCK_USER_TOKEN:
|
||||
return mockCredentials.user();
|
||||
@@ -56,6 +61,8 @@ export class MockAuthService implements AuthService {
|
||||
return mockCredentials.service();
|
||||
case MOCK_INVALID_USER_TOKEN:
|
||||
throw new AuthenticationError('User token is invalid');
|
||||
case MOCK_INVALID_USER_LIMITED_TOKEN:
|
||||
throw new AuthenticationError('Limited user token is invalid');
|
||||
case MOCK_INVALID_SERVICE_TOKEN:
|
||||
throw new AuthenticationError('Service token is invalid');
|
||||
case '':
|
||||
@@ -72,6 +79,18 @@ export class MockAuthService implements AuthService {
|
||||
return mockCredentials.user(userEntityRef);
|
||||
}
|
||||
|
||||
if (token.startsWith(MOCK_USER_LIMITED_TOKEN_PREFIX)) {
|
||||
if (!options?.allowLimitedAccess) {
|
||||
throw new AuthenticationError('Limited user token is not allowed');
|
||||
}
|
||||
|
||||
const { sub: userEntityRef }: UserTokenPayload = JSON.parse(
|
||||
token.slice(MOCK_USER_LIMITED_TOKEN_PREFIX.length),
|
||||
);
|
||||
|
||||
return mockCredentials.user(userEntityRef);
|
||||
}
|
||||
|
||||
if (token.startsWith(MOCK_SERVICE_TOKEN_PREFIX)) {
|
||||
const { sub, target, obo }: ServiceTokenPayload = JSON.parse(
|
||||
token.slice(MOCK_SERVICE_TOKEN_PREFIX.length),
|
||||
@@ -92,6 +111,10 @@ export class MockAuthService implements AuthService {
|
||||
throw new AuthenticationError(`Unknown mock token '${token}'`);
|
||||
}
|
||||
|
||||
async getNoneCredentials() {
|
||||
return mockCredentials.none();
|
||||
}
|
||||
|
||||
async getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
> {
|
||||
@@ -144,4 +167,21 @@ export class MockAuthService implements AuthService {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
if (credentials.principal.type !== 'user') {
|
||||
throw new AuthenticationError(
|
||||
`Refused to issue limited user token for credential type '${credentials.principal.type}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token: mockCredentials.limitedUser.token(
|
||||
credentials.principal.userEntityRef,
|
||||
),
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,11 @@ import { AuthenticationError } from '@backstage/errors';
|
||||
describe('MockHttpAuthService', () => {
|
||||
const httpAuth = new MockHttpAuthService('test', mockCredentials.none());
|
||||
|
||||
function makeAuthReq(header?: string) {
|
||||
return { headers: { authorization: header } } as Request;
|
||||
function makeAuthReq(authorization?: string) {
|
||||
return { headers: { authorization } } as Request;
|
||||
}
|
||||
function makeCookieAuthReq(cookie?: string) {
|
||||
return { headers: { cookie } } as Request;
|
||||
}
|
||||
|
||||
it('should authenticate unauthenticated requests', async () => {
|
||||
@@ -68,6 +71,59 @@ describe('MockHttpAuthService', () => {
|
||||
).resolves.toEqual(mockCredentials.user('user:default/other'));
|
||||
});
|
||||
|
||||
it('should authenticate limited user requests', async () => {
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeCookieAuthReq(mockCredentials.limitedUser.cookie()),
|
||||
),
|
||||
).resolves.toEqual(mockCredentials.none());
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeCookieAuthReq(mockCredentials.limitedUser.cookie()),
|
||||
{ allowLimitedAccess: true },
|
||||
),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(makeAuthReq(mockCredentials.user.header()), {
|
||||
allowLimitedAccess: true,
|
||||
}),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeCookieAuthReq(mockCredentials.limitedUser.cookie()),
|
||||
{
|
||||
allow: ['user'],
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('Missing credentials');
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeCookieAuthReq(mockCredentials.limitedUser.cookie()),
|
||||
{
|
||||
allow: ['none', 'service'],
|
||||
allowLimitedAccess: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("This endpoint does not allow 'user' credentials");
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeAuthReq(`Bearer ${mockCredentials.limitedUser.token()}`),
|
||||
{ allowLimitedAccess: true },
|
||||
),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeAuthReq(`Bearer ${mockCredentials.limitedUser.token()}`),
|
||||
),
|
||||
).rejects.toThrow('Limited user token is not allowed');
|
||||
});
|
||||
|
||||
it('should authenticate service requests', async () => {
|
||||
await expect(
|
||||
httpAuth.credentials(makeAuthReq(mockCredentials.service.header())),
|
||||
@@ -161,9 +217,42 @@ describe('MockHttpAuthService', () => {
|
||||
).rejects.toThrow('Service token is invalid');
|
||||
});
|
||||
|
||||
it('does not implement .issueUserCookie', async () => {
|
||||
await expect(httpAuth.issueUserCookie({} as any)).rejects.toThrow(
|
||||
'Not implemented',
|
||||
it('should issue user cookie from request credentials', async () => {
|
||||
const setHeader = jest.fn();
|
||||
|
||||
await expect(
|
||||
httpAuth.issueUserCookie({
|
||||
req: makeAuthReq(mockCredentials.user.header()),
|
||||
setHeader,
|
||||
} as any),
|
||||
).resolves.toEqual({
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
'Set-Cookie',
|
||||
mockCredentials.limitedUser.cookie(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should issue user cookie from explicit credentials', async () => {
|
||||
const setHeader = jest.fn();
|
||||
|
||||
await expect(
|
||||
httpAuth.issueUserCookie(
|
||||
{
|
||||
req: makeAuthReq(mockCredentials.user.header()),
|
||||
setHeader,
|
||||
} as any,
|
||||
{ credentials: mockCredentials.user('user:default/other') },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
'Set-Cookie',
|
||||
mockCredentials.limitedUser.cookie('user:default/other'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,16 +18,18 @@ import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageUserPrincipal,
|
||||
HttpAuthService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Request, Response } from 'express';
|
||||
import { parse as parseCookie } from 'cookie';
|
||||
import { MockAuthService } from './MockAuthService';
|
||||
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
|
||||
import {
|
||||
AuthenticationError,
|
||||
NotAllowedError,
|
||||
NotImplementedError,
|
||||
} from '@backstage/errors';
|
||||
import { mockCredentials } from './mockCredentials';
|
||||
MOCK_NONE_TOKEN,
|
||||
MOCK_AUTH_COOKIE,
|
||||
mockCredentials,
|
||||
} from './mockCredentials';
|
||||
|
||||
// TODO: support mock cookie auth?
|
||||
export class MockHttpAuthService implements HttpAuthService {
|
||||
@@ -42,33 +44,52 @@ export class MockHttpAuthService implements HttpAuthService {
|
||||
this.#defaultCredentials = defaultCredentials;
|
||||
}
|
||||
|
||||
async #getCredentials(req: Request) {
|
||||
async #getCredentials(req: Request, allowLimitedAccess: boolean) {
|
||||
const header = req.headers.authorization;
|
||||
|
||||
if (header === mockCredentials.none.header()) {
|
||||
return mockCredentials.none();
|
||||
}
|
||||
|
||||
const token =
|
||||
typeof header === 'string'
|
||||
? header.match(/^Bearer[ ]+(\S+)$/i)?.[1]
|
||||
: undefined;
|
||||
|
||||
if (!token) {
|
||||
return this.#defaultCredentials;
|
||||
if (token) {
|
||||
if (token === MOCK_NONE_TOKEN) {
|
||||
return this.#auth.getNoneCredentials();
|
||||
}
|
||||
|
||||
return await this.#auth.authenticate(token, {
|
||||
allowLimitedAccess,
|
||||
});
|
||||
}
|
||||
|
||||
return await this.#auth.authenticate(token);
|
||||
if (allowLimitedAccess) {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
|
||||
if (cookieHeader) {
|
||||
const cookies = parseCookie(cookieHeader);
|
||||
const cookie = cookies[MOCK_AUTH_COOKIE];
|
||||
|
||||
if (cookie) {
|
||||
return await this.#auth.authenticate(cookie, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.#defaultCredentials;
|
||||
}
|
||||
|
||||
async credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
|
||||
req: Request,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
|
||||
const credentials = await this.#getCredentials(req);
|
||||
const credentials = await this.#getCredentials(
|
||||
req,
|
||||
options?.allowLimitedAccess ?? false,
|
||||
);
|
||||
|
||||
const allowedPrincipalTypes = options?.allow;
|
||||
if (!allowedPrincipalTypes) {
|
||||
@@ -80,7 +101,7 @@ export class MockHttpAuthService implements HttpAuthService {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new AuthenticationError();
|
||||
throw new AuthenticationError('Missing credentials');
|
||||
} else if (this.#auth.isPrincipal(credentials, 'user')) {
|
||||
if (allowedPrincipalTypes.includes('user' as TAllowed)) {
|
||||
return credentials as any;
|
||||
@@ -104,7 +125,19 @@ export class MockHttpAuthService implements HttpAuthService {
|
||||
);
|
||||
}
|
||||
|
||||
async issueUserCookie(_res: Response): Promise<void> {
|
||||
throw new NotImplementedError('Not implemented');
|
||||
async issueUserCookie(
|
||||
res: Response,
|
||||
options?: { credentials?: BackstageCredentials<BackstageUserPrincipal> },
|
||||
): Promise<{ expiresAt: Date }> {
|
||||
const credentials =
|
||||
options?.credentials ??
|
||||
(await this.credentials(res.req, { allow: ['user'] }));
|
||||
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
mockCredentials.limitedUser.cookie(credentials.principal.userEntityRef),
|
||||
);
|
||||
|
||||
return { expiresAt: new Date(Date.now() + 3600_000) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MockUserInfoService } from './MockUserInfoService';
|
||||
import { mockCredentials } from './mockCredentials';
|
||||
|
||||
describe('MockUserInfoService', () => {
|
||||
it('works without constructor parameters', async () => {
|
||||
const service = new MockUserInfoService();
|
||||
const user = mockCredentials.user();
|
||||
await expect(service.getUserInfo(user)).resolves.toEqual({
|
||||
userEntityRef: user.principal.userEntityRef,
|
||||
ownershipEntityRefs: [user.principal.userEntityRef],
|
||||
});
|
||||
});
|
||||
|
||||
it('works with custom constructor parameters', async () => {
|
||||
const service = new MockUserInfoService({
|
||||
userEntityRef: 'user:default/not-the-mock-1',
|
||||
ownershipEntityRefs: ['user:default/not-the-mock-2'],
|
||||
});
|
||||
const user = mockCredentials.user();
|
||||
await expect(service.getUserInfo(user)).resolves.toEqual({
|
||||
userEntityRef: 'user:default/not-the-mock-1',
|
||||
ownershipEntityRefs: ['user:default/not-the-mock-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-users', async () => {
|
||||
const service = new MockUserInfoService();
|
||||
await expect(
|
||||
service.getUserInfo(mockCredentials.none()),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"User info not available for principal type 'none'"`,
|
||||
);
|
||||
await expect(
|
||||
service.getUserInfo(mockCredentials.service()),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"User info not available for principal type 'service'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstageCredentials,
|
||||
BackstageNonePrincipal,
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserInfo,
|
||||
BackstageUserPrincipal,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { InputError } from '@backstage/errors';
|
||||
|
||||
/** @internal */
|
||||
export class MockUserInfoService implements UserInfoService {
|
||||
private readonly customInfo: Partial<BackstageUserInfo>;
|
||||
|
||||
constructor(customInfo?: Partial<BackstageUserInfo>) {
|
||||
this.customInfo = customInfo ?? {};
|
||||
}
|
||||
|
||||
async getUserInfo(
|
||||
credentials: BackstageCredentials,
|
||||
): Promise<BackstageUserInfo> {
|
||||
const principal = credentials.principal as
|
||||
| BackstageUserPrincipal
|
||||
| BackstageServicePrincipal
|
||||
| BackstageNonePrincipal;
|
||||
|
||||
if (principal.type !== 'user') {
|
||||
throw new InputError(
|
||||
`User info not available for principal type '${principal.type}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
userEntityRef: principal.userEntityRef,
|
||||
ownershipEntityRefs: [principal.userEntityRef],
|
||||
...this.customInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,18 @@ describe('mockCredentials', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a mocked credentials object for a limited user principal', () => {
|
||||
expect(mockCredentials.limitedUser()).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/mock' },
|
||||
});
|
||||
|
||||
expect(mockCredentials.limitedUser('user:default/other')).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/other' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a mocked credentials object for a service principal', () => {
|
||||
expect(mockCredentials.service()).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
@@ -68,6 +80,22 @@ describe('mockCredentials', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('creates limited user tokens and headers', () => {
|
||||
expect(mockCredentials.limitedUser.token('user:default/other')).toBe(
|
||||
'mock-limited-user-token:{"sub":"user:default/other"}',
|
||||
);
|
||||
expect(mockCredentials.limitedUser.invalidToken()).toBe(
|
||||
'mock-invalid-limited-user-token',
|
||||
);
|
||||
|
||||
expect(mockCredentials.limitedUser.cookie('user:default/other')).toBe(
|
||||
'backstage-auth=mock-limited-user-token:{"sub":"user:default/other"}',
|
||||
);
|
||||
expect(mockCredentials.limitedUser.invalidCookie()).toBe(
|
||||
'backstage-auth=mock-invalid-limited-user-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates service tokens and headers', () => {
|
||||
expect(mockCredentials.service.token()).toBe('mock-service-token');
|
||||
expect(
|
||||
|
||||
@@ -24,10 +24,18 @@ import {
|
||||
export const DEFAULT_MOCK_USER_ENTITY_REF = 'user:default/mock';
|
||||
export const DEFAULT_MOCK_SERVICE_SUBJECT = 'external:test-service';
|
||||
|
||||
export const MOCK_AUTH_COOKIE = 'backstage-auth';
|
||||
|
||||
export const MOCK_NONE_TOKEN = 'mock-none-token';
|
||||
|
||||
export const MOCK_USER_TOKEN = 'mock-user-token';
|
||||
export const MOCK_USER_TOKEN_PREFIX = 'mock-user-token:';
|
||||
export const MOCK_INVALID_USER_TOKEN = 'mock-invalid-user-token';
|
||||
|
||||
export const MOCK_USER_LIMITED_TOKEN_PREFIX = 'mock-limited-user-token:';
|
||||
export const MOCK_INVALID_USER_LIMITED_TOKEN =
|
||||
'mock-invalid-limited-user-token';
|
||||
|
||||
export const MOCK_SERVICE_TOKEN = 'mock-service-token';
|
||||
export const MOCK_SERVICE_TOKEN_PREFIX = 'mock-service-token:';
|
||||
export const MOCK_INVALID_SERVICE_TOKEN = 'mock-invalid-service-token';
|
||||
@@ -143,6 +151,54 @@ export namespace mockCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mocked credentials object for a user principal with limited
|
||||
* access.
|
||||
*
|
||||
* The default user entity reference is 'user:default/mock'.
|
||||
*/
|
||||
export function limitedUser(
|
||||
userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF,
|
||||
): BackstageCredentials<BackstageUserPrincipal> {
|
||||
return user(userEntityRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities related to limited user credentials.
|
||||
*/
|
||||
export namespace limitedUser {
|
||||
/**
|
||||
* Creates a mocked limited user token. If a payload is provided it will be
|
||||
* encoded into the token and forwarded to the credentials object when
|
||||
* authenticated by the mock auth service.
|
||||
*/
|
||||
export function token(
|
||||
userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF,
|
||||
): string {
|
||||
validateUserEntityRef(userEntityRef);
|
||||
return `${MOCK_USER_LIMITED_TOKEN_PREFIX}${JSON.stringify({
|
||||
sub: userEntityRef,
|
||||
} satisfies UserTokenPayload)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an authorization header with a mocked limited user token. If a
|
||||
* payload is provided it will be encoded into the token and forwarded to
|
||||
* the credentials object when authenticated by the mock auth service.
|
||||
*/
|
||||
export function cookie(userEntityRef?: string): string {
|
||||
return `${MOCK_AUTH_COOKIE}=${token(userEntityRef)}`;
|
||||
}
|
||||
|
||||
export function invalidToken(): string {
|
||||
return MOCK_INVALID_USER_LIMITED_TOKEN;
|
||||
}
|
||||
|
||||
export function invalidCookie(): string {
|
||||
return `${MOCK_AUTH_COOKIE}=${invalidToken()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mocked credentials object for a service principal.
|
||||
*
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
DiscoveryService,
|
||||
HttpAuthService,
|
||||
BackstageCredentials,
|
||||
BackstageUserInfo,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
cacheServiceFactory,
|
||||
@@ -49,6 +51,7 @@ import { MockRootLoggerService } from './MockRootLoggerService';
|
||||
import { MockAuthService } from './MockAuthService';
|
||||
import { MockHttpAuthService } from './MockHttpAuthService';
|
||||
import { mockCredentials } from './mockCredentials';
|
||||
import { MockUserInfoService } from './MockUserInfoService';
|
||||
|
||||
/** @internal */
|
||||
function simpleFactory<
|
||||
@@ -199,9 +202,11 @@ export namespace mockServices {
|
||||
});
|
||||
export const mock = simpleMock(coreServices.auth, () => ({
|
||||
authenticate: jest.fn(),
|
||||
getNoneCredentials: jest.fn(),
|
||||
getOwnServiceCredentials: jest.fn(),
|
||||
isPrincipal: jest.fn() as any,
|
||||
getPluginRequestToken: jest.fn(),
|
||||
getLimitedUserToken: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -272,6 +277,37 @@ export namespace mockServices {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock implementation of the `UserInfoService`.
|
||||
*
|
||||
* By default it extracts the user's entity ref from a user principal and
|
||||
* returns that as the only ownership entity ref, but this can be overridden
|
||||
* by passing in a custom set of user info.
|
||||
*/
|
||||
export function userInfo(
|
||||
customInfo?: Partial<BackstageUserInfo>,
|
||||
): UserInfoService {
|
||||
return new MockUserInfoService(customInfo);
|
||||
}
|
||||
export namespace userInfo {
|
||||
/**
|
||||
* Creates a mock service factory for the `UserInfoService`.
|
||||
*
|
||||
* By default it extracts the user's entity ref from a user principal and
|
||||
* returns that as the only ownership entity ref.
|
||||
*/
|
||||
export const factory = createServiceFactory({
|
||||
service: coreServices.userInfo,
|
||||
deps: {},
|
||||
factory() {
|
||||
return new MockUserInfoService();
|
||||
},
|
||||
});
|
||||
export const mock = simpleMock(coreServices.userInfo, () => ({
|
||||
getUserInfo: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
// TODO(Rugvip): Not all core services have implementations available here yet.
|
||||
// some may need a bit more refactoring for it to be simpler to
|
||||
// re-implement functioning mock versions here.
|
||||
@@ -284,12 +320,14 @@ export namespace mockServices {
|
||||
withOptions: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace database {
|
||||
export const factory = databaseServiceFactory;
|
||||
export const mock = simpleMock(coreServices.database, () => ({
|
||||
getClient: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace httpRouter {
|
||||
export const factory = httpRouterServiceFactory;
|
||||
export const mock = simpleMock(coreServices.httpRouter, () => ({
|
||||
@@ -297,12 +335,14 @@ export namespace mockServices {
|
||||
addAuthPolicy: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace rootHttpRouter {
|
||||
export const factory = rootHttpRouterServiceFactory;
|
||||
export const mock = simpleMock(coreServices.rootHttpRouter, () => ({
|
||||
use: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace lifecycle {
|
||||
export const factory = lifecycleServiceFactory;
|
||||
export const mock = simpleMock(coreServices.lifecycle, () => ({
|
||||
@@ -310,6 +350,7 @@ export namespace mockServices {
|
||||
addStartupHook: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace logger {
|
||||
export const factory = loggerServiceFactory;
|
||||
export const mock = simpleMock(coreServices.logger, () => ({
|
||||
@@ -320,6 +361,7 @@ export namespace mockServices {
|
||||
warn: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace permissions {
|
||||
export const factory = permissionsServiceFactory;
|
||||
export const mock = simpleMock(coreServices.permissions, () => ({
|
||||
@@ -327,6 +369,7 @@ export namespace mockServices {
|
||||
authorizeConditional: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace rootLifecycle {
|
||||
export const factory = rootLifecycleServiceFactory;
|
||||
export const mock = simpleMock(coreServices.rootLifecycle, () => ({
|
||||
@@ -334,6 +377,7 @@ export namespace mockServices {
|
||||
addStartupHook: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace scheduler {
|
||||
export const factory = schedulerServiceFactory;
|
||||
export const mock = simpleMock(coreServices.scheduler, () => ({
|
||||
@@ -343,6 +387,7 @@ export namespace mockServices {
|
||||
triggerTask: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
export namespace urlReader {
|
||||
export const factory = urlReaderServiceFactory;
|
||||
export const mock = simpleMock(coreServices.urlReader, () => ({
|
||||
|
||||
@@ -80,6 +80,7 @@ export const defaultServiceFactories = [
|
||||
mockServices.rootLogger.factory(),
|
||||
mockServices.scheduler.factory(),
|
||||
mockServices.tokenManager.factory(),
|
||||
mockServices.userInfo.factory(),
|
||||
mockServices.urlReader.factory(),
|
||||
];
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import { PluginEnvironment } from './types';
|
||||
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { DefaultEventBroker } from '@backstage/plugin-events-backend';
|
||||
import { DefaultEventsService } from '@backstage/plugin-events-node';
|
||||
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
|
||||
import { MeterProvider } from '@opentelemetry/sdk-metrics';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
@@ -99,7 +100,11 @@ function makeCreateEnv(config: Config) {
|
||||
discovery,
|
||||
});
|
||||
|
||||
const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' }));
|
||||
const eventsService = DefaultEventsService.create({ logger: root });
|
||||
const eventBroker = new DefaultEventBroker(
|
||||
root.child({ type: 'plugin' }),
|
||||
eventsService,
|
||||
);
|
||||
const signalService = DefaultSignalService.create({
|
||||
eventBroker,
|
||||
});
|
||||
@@ -119,6 +124,7 @@ function makeCreateEnv(config: Config) {
|
||||
config,
|
||||
reader,
|
||||
eventBroker,
|
||||
events: eventsService,
|
||||
discovery,
|
||||
tokenManager,
|
||||
permissions,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user