Merge branch 'backstage:master' into catelog-documentation-fix

This commit is contained in:
Ismail M
2024-06-02 00:43:02 +03:00
committed by GitHub
49 changed files with 1281 additions and 815 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Marked the `TokenManagerService` and `IdentityService` types as deprecated
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Refactored `TestDatabases` to no longer depend on `backend-common`
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-defaults': patch
'@backstage/backend-common': patch
---
Deprecated `dropDatabase`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-node': patch
---
Import `tokenManager` definition from `@backstage/backend-plugin-api`
@@ -16,36 +16,42 @@ import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { Router } from 'express';
import { NotAllowedError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import Router from 'express-promise-router';
createBackendPlugin({
export default createBackendPlugin({
pluginId: 'example',
register(env) {
env.registerInit({
deps: {
permissions: coreServices.permissions,
http: coreServices.httpRouter,
httpRouter: coreServices.httpRouter,
httpAuth: coreServices.httpAuth,
},
async init({ permissions, http }) {
const router = Router();
router.get('/test-me', (request, response) => {
// use the identity service to pull out the token from request headers
const { token } = await identity.getIdentity({
request,
});
// ask the permissions framework what the decision is for the permission
async init({ permissions, httpRouter, httpAuth }) {
const endpoints = Router();
endpoints.get('/test-me', (request, response) => {
// Ask the permissions framework what the decision is for the given
// permission, for the principal that made the original request. The
// `httpAuth` service helps us extract those credentials. We authorize
// a single permission here, so the result will be an array with one
// element accordingly.
const permissionResponse = await permissions.authorize(
[
{
permission: myCustomPermission,
},
],
{ token },
[{ permission: myCustomPermission }],
{ credentials: await httpAuth.credentials(request) },
);
if (permissionResponse[0].result !== AuthorizeResult.ALLOW) {
throw new NotAllowedError(
'You are not permitted to perform this action',
);
}
// TODO: Actual code goes here
});
http.use(router);
httpRouter.use(endpoints);
},
});
},
@@ -5,4 +5,28 @@ sidebar_label: Plugin Metadata
description: Documentation for the Plugin Metadata service
---
TODO
This service allows you to query for metadata about the current plugin. In particular, this service is used by other plugin-scoped services, if they need to know what the ID is of the plugin that they are being instantiated for.
## Using the service
The following example shows a fake plugin-scoped service which wants to know what plugin it "belongs" to.
```ts
import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
export const myServiceFactory = createServiceFactory({
service: myServiceRef,
deps: {
logger: coreServices.logger,
plugin: coreServices.pluginMetadata,
},
async factory({ logger, plugin }) {
const pluginId = plugin.getId();
logger.info(`Creating an instance of my service for plugin '${id}'`);
return ...; // TODO
},
});
```
+1 -1
View File
@@ -17,7 +17,7 @@
"build:all": "backstage-cli repo build --all",
"build:api-docs": "LANG=en_EN yarn build:api-reports --docs --exclude 'plugins/@(adr|adr-backend|adr-common|airbrake|airbrake-backend|allure|analytics-module-ga|analytics-module-ga4|analytics-module-newrelic-browser|apache-airflow|api-docs|api-docs-module-protoc-gen-doc|apollo-explorer|app-visualizer|azure-devops|azure-devops-backend|azure-devops-common|azure-sites|azure-sites-backend|azure-sites-common|badges|badges-backend|bazaar|bazaar-backend|bitbucket-cloud-common|bitrise|catalog-graph|catalog-graphql|catalog-import|catalog-unprocessed-entities|cicd-statistics|cicd-statistics-module-gitlab|circleci|cloudbuild|code-climate|code-coverage|code-coverage-backend|codescene|config-schema|cost-insights|cost-insights-common|dynatrace|entity-feedback|entity-feedback-backend|entity-feedback-common|entity-validation|example-todo-list|example-todo-list-backend|example-todo-list-common|firehydrant|fossa|gcalendar|gcp-projects|git-release-manager|github-actions|github-deployments|github-issues|github-pull-requests-board|gitops-profiles|gocd|graphiql|graphql-backend|graphql-voyager|ilert|jenkins|jenkins-backend|jenkins-common|kafka|kafka-backend|lighthouse|lighthouse-backend|lighthouse-common|linguist|linguist-backend|linguist-common|microsoft-calendar|newrelic|newrelic-dashboard|nomad|nomad-backend|octopus-deploy|opencost|pagerduty|periskop|periskop-backend|playlist|playlist-backend|playlist-common|proxy-backend|puppetdb|rollbar|rollbar-backend|sentry|shortcuts|splunk-on-call|stack-overflow|stack-overflow-backend|stackstorm|tech-radar|tech-radar-2|todo|todo-backend|xcmetrics)'",
"build:api-reports": "yarn build:api-reports:only --tsc",
"build:api-reports:only": "backstage-repo-tools api-reports --allow-warnings 'packages/core-components,plugins/+(catalog|catalog-import|git-release-manager|jenkins|kubernetes)' -o ae-wrong-input-file-type --validate-release-tags",
"build:api-reports:only": "NODE_OPTIONS=--max-old-space-size=8192 backstage-repo-tools api-reports --allow-warnings 'packages/core-components,plugins/+(catalog|catalog-import|git-release-manager|jenkins|kubernetes)' -o ae-wrong-input-file-type --validate-release-tags",
"build:backend": "yarn workspace example-backend build",
"build:knip-reports": "backstage-repo-tools knip-reports",
"build:plugins-report": "node ./scripts/build-plugins-report",
+1 -1
View File
@@ -312,7 +312,7 @@ export class DockerContainerRunner implements ContainerRunner {
runContainer(options: RunContainerOptions): Promise<void>;
}
// @public
// @public @deprecated
export function dropDatabase(
dbConfig: Config,
...databaseNames: string[]
@@ -38,7 +38,7 @@ export const databaseServiceFactory: () => ServiceFactory<
'plugin'
>;
// @public
// @public @deprecated
export function dropDatabase(
dbConfig: Config,
...databaseNames: string[]
@@ -234,9 +234,10 @@ export class DatabaseManager implements LegacyRootDatabaseService {
}
/**
* Helper for deleting databases, only exists for backend-test-utils for now.
* Helper for deleting databases.
*
* @public
* @deprecated Will be removed in a future release.
*/
export async function dropDatabase(
dbConfig: Config,
+20 -35
View File
@@ -24,41 +24,34 @@ import { Readable } from 'stream';
import { Request as Request_2 } from 'express';
import { Response as Response_2 } from 'express';
// @public (undocumented)
// @public
export interface AuthService {
// (undocumented)
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>
>;
// (undocumented)
getPluginRequestToken(options: {
onBehalfOf: BackstageCredentials;
targetPluginId: string;
}): Promise<{
token: string;
}>;
// (undocumented)
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
credentials: BackstageCredentials,
type: TType,
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
// (undocumented)
listPublicServiceKeys(): Promise<{
keys: JsonObject[];
}>;
@@ -116,14 +109,14 @@ export interface BackendPluginRegistrationPoints {
}): void;
}
// @public (undocumented)
// @public
export type BackstageCredentials<TPrincipal = unknown> = {
$$type: '@backstage/BackstageCredentials';
expiresAt?: Date;
principal: TPrincipal;
};
// @public (undocumented)
// @public
export type BackstageNonePrincipal = {
type: 'none';
};
@@ -136,7 +129,7 @@ export type BackstagePrincipalAccessRestrictions = {
};
};
// @public (undocumented)
// @public
export type BackstagePrincipalTypes = {
user: BackstageUserPrincipal;
service: BackstageServicePrincipal;
@@ -144,14 +137,14 @@ export type BackstagePrincipalTypes = {
unknown: unknown;
};
// @public (undocumented)
// @public
export type BackstageServicePrincipal = {
type: 'service';
subject: string;
accessRestrictions?: BackstagePrincipalAccessRestrictions;
};
// @public (undocumented)
// @public
export interface BackstageUserInfo {
// (undocumented)
ownershipEntityRefs: string[];
@@ -159,7 +152,7 @@ export interface BackstageUserInfo {
userEntityRef: string;
}
// @public (undocumented)
// @public
export type BackstageUserPrincipal = {
type: 'user';
userEntityRef: string;
@@ -334,9 +327,8 @@ export type ExtensionPoint<T> = {
// @public @deprecated (undocumented)
export type ExtensionPointConfig = CreateExtensionPointOptions;
// @public (undocumented)
// @public
export interface HttpAuthService {
// (undocumented)
credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
req: Request_2<any, any, any, any, any>,
options?: {
@@ -344,7 +336,6 @@ export interface HttpAuthService {
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
// (undocumented)
issueUserCookie(
res: Response_2,
options?: {
@@ -355,15 +346,13 @@ export interface HttpAuthService {
}>;
}
// @public (undocumented)
// @public
export interface HttpRouterService {
// (undocumented)
addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void;
// (undocumented)
use(handler: Handler): void;
}
// @public (undocumented)
// @public
export interface HttpRouterServiceAuthPolicy {
// (undocumented)
allow: 'unauthenticated' | 'user-cookie';
@@ -371,7 +360,7 @@ export interface HttpRouterServiceAuthPolicy {
path: string;
}
// @public (undocumented)
// @public @deprecated
export interface IdentityService extends IdentityApi {}
export { isChildPath };
@@ -379,7 +368,7 @@ export { isChildPath };
// @public
export function isDatabaseConflictError(e: unknown): boolean;
// @public (undocumented)
// @public
export interface LifecycleService {
addShutdownHook(
hook: LifecycleServiceShutdownHook,
@@ -421,14 +410,12 @@ export interface LoggerService {
warn(message: string, meta?: Error | JsonObject): void;
}
// @public (undocumented)
// @public
export interface PermissionsService extends PermissionEvaluator {
// (undocumented)
authorize(
requests: AuthorizePermissionRequest[],
options?: PermissionsServiceRequestOptions,
): Promise<AuthorizePermissionResponse[]>;
// (undocumented)
authorizeConditional(
requests: QueryPermissionRequest[],
options?: PermissionsServiceRequestOptions,
@@ -444,9 +431,8 @@ export type PermissionsServiceRequestOptions =
credentials: BackstageCredentials;
};
// @public (undocumented)
// @public
export interface PluginMetadataService {
// (undocumented)
getId(): string;
}
@@ -535,18 +521,18 @@ export function resolvePackagePath(name: string, ...paths: string[]): string;
// @public
export function resolveSafeChildPath(base: string, path: string): string;
// @public (undocumented)
// @public
export interface RootConfigService extends Config {}
// @public (undocumented)
// @public
export interface RootHttpRouterService {
use(path: string, handler: Handler): void;
}
// @public (undocumented)
// @public
export interface RootLifecycleService extends LifecycleService {}
// @public (undocumented)
// @public
export interface RootLoggerService extends LoggerService {}
// @public (undocumented)
@@ -686,7 +672,7 @@ export interface ServiceRefConfig<TService, TScope extends 'root' | 'plugin'> {
scope?: TScope;
}
// @public
// @public @deprecated
export interface TokenManagerService {
authenticate(token: string): Promise<void>;
getToken(): Promise<{
@@ -701,9 +687,8 @@ export interface UrlReaderService {
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
}
// @public (undocumented)
// @public
export interface UserInfoService {
// (undocumented)
getUserInfo(credentials: BackstageCredentials): Promise<BackstageUserInfo>;
}
```
@@ -18,15 +18,29 @@ import { PermissionAttributes } from '@backstage/plugin-permission-common';
import { JsonObject } from '@backstage/types';
/**
* Represents a user principal (for example when a user Backstage token issued
* by the auth backend was given to a request).
*
* @remarks
*
* Additional information about the user can be fetched using the
* {@link UserInfoService}.
*
* @public
*/
export type BackstageUserPrincipal = {
type: 'user';
/**
* The entity ref of the user entity that this principal represents.
*/
userEntityRef: string;
};
/**
* Represents a principal that is not authenticated (for example when no token
* at all was given to a request).
*
* @public
*/
export type BackstageNonePrincipal = {
@@ -34,13 +48,22 @@ export type BackstageNonePrincipal = {
};
/**
* Represents a service principal (for example when an external access method
* token was given to a request, or the caller was a Backstage backend plugin).
* @public
*/
export type BackstageServicePrincipal = {
type: 'service';
// Exact format TBD, possibly 'plugin:<pluginId>' or 'external:<externalServiceId>'
subject: string;
/**
* A string that represents the service.
*
* @remarks
*
* This string is only informational, has no well defined semantics, and
* should never be used to drive actual logic in code.
*/
subject: string; // Exact format TBD, possibly 'plugin:<pluginId>' or 'external:<externalServiceId>'
/**
* The access restrictions that apply to this principal.
@@ -92,17 +115,38 @@ export type BackstagePrincipalAccessRestrictions = {
};
/**
* An opaque representation of credentials, for example as passed in a
* request-response flow.
*
* @public
*/
export type BackstageCredentials<TPrincipal = unknown> = {
$$type: '@backstage/BackstageCredentials';
/**
* If the credentials have a limited lifetime, this is the time at which they
* expire and may no longer be accepted by a receiver.
*/
expiresAt?: Date;
/**
* The principal (originator) of the request.
*
* @remarks
*
* This is semantically the originator of a request chain, and may or may not
* represent the immediate caller of your service. For example, in
* on-behalf-of scenarios, the immediate caller may be an intermediary backend
* service, but the principal may still be a user that was the original
* caller.
*/
principal: TPrincipal;
};
/**
* The types of principal that can be represented in a
* {@link BackstageCredentials} object.
*
* @public
*/
export type BackstagePrincipalTypes = {
@@ -113,36 +157,95 @@ export type BackstagePrincipalTypes = {
};
/**
* Provides token authentication and credentials management.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/auth | service documentation} for more details.
*
* @public
*/
export interface AuthService {
/**
* Verifies a token and returns the associated credentials.
*/
authenticate(
token: string,
options?: {
/**
* If set to true, allow limited access tokens (such as cookies).
*
* If this flag is not set, or is set to false, calls with limited access
* tokens will lead to a {@link @backstage/errors#NotAllowedError} being
* thrown.
*/
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials>;
/**
* Checks if the given credentials are of the given type, and narrows the
* TypeScript type accordingly if there's a match.
*/
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
credentials: BackstageCredentials,
type: TType,
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
/**
* Create a credentials object that represents an unauthenticated caller.
*/
getNoneCredentials(): Promise<BackstageCredentials<BackstageNonePrincipal>>;
/**
* Create a credentials object that represents the current service itself.
*/
getOwnServiceCredentials(): Promise<
BackstageCredentials<BackstageServicePrincipal>
>;
/**
* Issue a token that can be used for authenticating calls towards other
* backend plugins.
*
* @remarks
*
* This method should be called before each request. Do not cold on to the
* issued token and reuse it for future calls.
*/
getPluginRequestToken(options: {
/**
* The credentials of the originator of the request.
*
* @remarks
*
* This is most commonly the result of
* {@link AuthService.getOwnServiceCredentials} when the current service is
* the originator, or the output of {@link HttpAuthService.credentials} when
* performing requests on behalf of an incoming request identity.
*/
onBehalfOf: BackstageCredentials;
/**
* The ID of the plugin that the request is being made to.
*/
targetPluginId: string;
}): Promise<{ token: string }>;
/**
* Issue a limited user token that can be used e.g. in cookie flows.
*/
getLimitedUserToken(
/**
* The credentials that this token should represent. Must be a user
* principal. Commonly the output of {@link HttpAuthService.credentials} is
* used as the input.
*/
credentials: BackstageCredentials<BackstageUserPrincipal>,
): Promise<{ token: string; expiresAt: Date }>;
/**
* Retrieve the public keys that have been used to sign tokens that were
* issued by this service. This list is periodically pruned from keys that are
* significantly past their expiry.
*/
listPublicServiceKeys(): Promise<{
keys: JsonObject[];
}>;
@@ -47,6 +47,8 @@ export type CacheServiceOptions = {
* A pre-configured, storage agnostic cache service suitable for use by
* Backstage plugins.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/cache | service documentation} for more details.
*
* @public
*/
export interface CacheService {
@@ -17,7 +17,9 @@
import { Knex } from 'knex';
/**
* The DatabaseService manages access to databases that Plugins get.
* Manages access to databases that plugins get.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/database | service documentation} for more details.
*
* @public
*/
@@ -18,6 +18,10 @@
* The DiscoveryService is used to provide a mechanism for backend
* plugins to discover the endpoints for itself or other backend plugins.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/discovery | service documentation} for more details.
*
* @remarks
*
* The purpose of the discovery API is to allow for many different deployment
* setups and routing methods through a central configuration, instead
* of letting each individual plugin manage that configuration.
@@ -32,13 +36,15 @@ export interface DiscoveryService {
/**
* Returns the internal HTTP base URL for a given plugin, without a trailing slash.
*
* @remarks
*
* The returned URL should point to an internal endpoint for the plugin, with
* the shortest route possible. The URL should be used for service-to-service
* communication within a Backstage backend deployment.
*
* This method must always be called just before making a request, as opposed to
* fetching the URL when constructing an API client. That is to ensure that more
* flexible routing patterns can be supported.
* This method must always be called just before making each request, as opposed to
* fetching the URL once when constructing an API client. That is to ensure that more
* flexible routing patterns can be supported where a different result might be returned each time.
*
* For example, asking for the URL for `catalog` may return something
* like `http://10.1.2.3/api/catalog`
@@ -48,6 +54,8 @@ export interface DiscoveryService {
/**
* Returns the external HTTP base backend URL for a given plugin, without a trailing slash.
*
* @remarks
*
* The returned URL should point to an external endpoint for the plugin, such that
* it is reachable from the Backstage frontend and other external services. The returned
* URL should be usable for example as a callback / webhook URL.
@@ -17,19 +17,84 @@
import { Request, Response } from 'express';
import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService';
/** @public */
/**
* Provides handling of credentials in an ongoing request.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/http-auth | service documentation} for more details.
*
* @public
*/
export interface HttpAuthService {
/**
* Extracts the caller's credentials from a request.
*
* @remarks
*
* The credentials have been validated before returning, and are guaranteed to
* adhere to whatever policies have been added to this route using
* {@link HttpRouterService.addAuthPolicy}, if any.
*
* Further restrictions can be imposed by passing in options that control the
* allowed types of credential.
*
* You can narrow the returned credentials object to specific principal types
* using {@link AuthService.isPrincipal}.
*/
credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
/**
* An Express request object.
*/
req: Request<any, any, any, any, any>,
/**
* Optional further restrictions.
*/
options?: {
/**
* If specified, allow only principals of the given type(s).
*
* If the incoming credentials were not of a type that matched this
* restriction, a {@link @backstage/errors#NotAllowedError} is thrown.
*
* The default is to allow user and service principals.
*/
allow?: Array<TAllowed>;
/**
* If set to true, allow limited access tokens (such as cookies).
*
* If this flag is not set, or is set to false, calls with limited access
* tokens will lead to a {@link @backstage/errors#NotAllowedError} being
* thrown.
*/
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
/**
* Issues a limited access token as a cookie on the given response object.
* This is only possible for requests that were originally made with user
* credentials (such as a Backstage token).
*
* This must be called before sending any payload data.
*/
issueUserCookie(
/**
* An Express response object.
*/
res: Response,
/**
* Optional further settings.
*/
options?: {
/**
* Issue the cookie for this specific credential. Must be a "user" type
* principal, or a "none" type (which leads to deleting the cookie).
*
* @remarks
*
* Normally you do not have to specify this option, because the default
* behavior is to extract the credentials from the request that
* corresponded to the given respnse.
*/
credentials?: BackstageCredentials;
},
): Promise<{ expiresAt: Date }>;
@@ -16,17 +16,51 @@
import { Handler } from 'express';
/** @public */
/**
* Options for {@link HttpRouterService.addAuthPolicy}.
*
* @public
*/
export interface HttpRouterServiceAuthPolicy {
path: string;
allow: 'unauthenticated' | 'user-cookie';
}
/**
* Allows plugins to register HTTP routes.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/http-router | service documentation} for more details.
*
* @public
*/
export interface HttpRouterService {
/**
* Registers an Express request handler under the plugin's base router. This
* typically makes its base path `/api/<plugin-id>`.
*/
use(handler: Handler): void;
/**
* Adds an auth policy to the router. This is used to allow unauthenticated or
* cookie based access to parts of a plugin's API.
*
* @remarks
*
* The paths given follow the same pattern as the routers given to the `use`
* method, that is, they are relative to the plugin's base URL, and can
* contain placeholders.
*
* @example
*
* ```ts
* http.addAuthPolicy({
* path: '/static/:id',
* allow: 'user-cookie',
* });
* ```
*
* This allows limited access tokens via cookies on the
* `/api/<plugin-id>/static/*` paths, but not unauthenticated access.
*/
addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void;
}
@@ -16,5 +16,12 @@
import { IdentityApi } from '@backstage/plugin-auth-node';
/** @public */
/**
* This is the legacy service for identity handling in Backstage. Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/identity | service documentation} for more details.
*
* @public
* @deprecated Please {@link https://backstage.io/docs/tutorials/auth-service-migration | migrate} to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead.
*/
export interface IdentityService extends IdentityApi {}
@@ -47,6 +47,10 @@ export interface LifecycleServiceShutdownOptions {
}
/**
* Provides registration of plugin startup and shutdown lifecycle hooks.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/lifecycle | service documentation} for more details.
*
* @public
*/
export interface LifecycleService {
@@ -19,6 +19,8 @@ import { JsonObject } from '@backstage/types';
/**
* A service that provides a logging facility.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/logger | service documentation} for more details.
*
* @public
*/
export interface LoggerService {
@@ -37,13 +37,46 @@ export type PermissionsServiceRequestOptions =
credentials: BackstageCredentials;
};
/** @public */
/**
* Permission system integration for authorization of user/service actions.
*
* See the {@link https://backstage.io/docs/permissions/overview | permissions documentation}
* and the {@link https://backstage.io/docs/backend-system/core-services/permissions | service documentation}
* for more details.
*
* @public
*/
export interface PermissionsService extends PermissionEvaluator {
/**
* Evaluates
* {@link @backstage/plugin-permission-common#Permission | Permissions} and
* returns definitive decisions.
*
* @remarks
*
* The returned array has the same number of items, in the same order, as the
* given requests.
*/
authorize(
requests: AuthorizePermissionRequest[],
options?: PermissionsServiceRequestOptions,
): Promise<AuthorizePermissionResponse[]>;
/**
* Evaluates {@link @backstage/plugin-permission-common#ResourcePermission | ResourcePermissions} and returns both definitive and
* conditional decisions, depending on the configured
* {@link @backstage/plugin-permission-node#PermissionPolicy}.
*
* @remarks
*
* This method is useful when the
* caller needs more control over the processing of conditional decisions. For example, a plugin
* backend may want to use {@link @backstage/plugin-permission-common#PermissionCriteria | conditions} in a database query instead of
* evaluating each resource in memory.
*
* The returned array has the same number of items, in the same order, as the
* given requests.
*/
authorizeConditional(
requests: QueryPermissionRequest[],
options?: PermissionsServiceRequestOptions,
@@ -15,8 +15,15 @@
*/
/**
* Access metadata about the current plugin.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/plugin-metadata | service documentation} for more details.
*
* @public
*/
export interface PluginMetadataService {
/**
* The ID of the current plugin.
*/
getId(): string;
}
@@ -17,6 +17,12 @@
import { Config } from '@backstage/config';
/**
* Provides access to static configuration.
*
* See the {@link https://backstage.io/docs/conf/ | configuration documentation}
* and the {@link https://backstage.io/docs/backend-system/core-services/root-config | service documentation}
* for more details.
*
* @public
*/
export interface RootConfigService extends Config {}
@@ -17,6 +17,10 @@
import { Handler } from 'express';
/**
* HTTP route registration for root services.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/root-http-router | service documentation} for more details.
*
* @public
*/
export interface RootHttpRouterService {
@@ -16,5 +16,11 @@
import { LifecycleService } from './LifecycleService';
/** @public */
/**
* Registration of backend startup and shutdown lifecycle hooks.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/root-lifecycle | service documentation} for more details.
*
* @public
*/
export interface RootLifecycleService extends LifecycleService {}
@@ -16,5 +16,11 @@
import { LoggerService } from './LoggerService';
/** @public */
/**
* Root-level logging.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/root-logger | service documentation} for more details.
*
* @public
*/
export interface RootLoggerService extends LoggerService {}
@@ -285,6 +285,8 @@ export interface SchedulerServiceTaskRunner {
/**
* Deals with the scheduling of distributed tasks, for a given plugin.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/scheduler | service documentation} for more details.
*
* @public
*/
export interface SchedulerService {
@@ -15,9 +15,12 @@
*/
/**
* Interface for creating and validating tokens.
* This is the legacy service for creating and validating tokens. Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/token-manager | service documentation} for more details.
*
* @public
* @deprecated Please {@link https://backstage.io/docs/tutorials/auth-service-migration | migrate} to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead.
*/
export interface TokenManagerService {
/**
@@ -19,6 +19,8 @@ import { Readable } from 'stream';
/**
* A generic interface for fetching plain data from URLs.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/url-reader | service documentation} for more details.
*
* @public
*/
export interface UrlReaderService {
@@ -16,13 +16,27 @@
import { BackstageCredentials } from './AuthService';
/** @public */
/**
* Represents user information that is available to the backend, based on some
* user credentials.
*
* @public
*/
export interface BackstageUserInfo {
userEntityRef: string;
ownershipEntityRefs: string[];
}
/** @public */
/**
* Authenticated user information retrieval.
*
* See the {@link https://backstage.io/docs/backend-system/core-services/user-info | service documentation} for more details.
*
* @public
*/
export interface UserInfoService {
/**
* Retrieve user information based on the provided credentials.
*/
getUserInfo(credentials: BackstageCredentials): Promise<BackstageUserInfo>;
}
+3 -2
View File
@@ -46,7 +46,6 @@
},
"dependencies": {
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
@@ -65,9 +64,11 @@
"msw": "^1.0.0",
"mysql2": "^3.0.0",
"pg": "^8.11.3",
"pg-connection-string": "^2.3.0",
"testcontainers": "^10.0.0",
"textextensions": "^5.16.0",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
@@ -14,28 +14,11 @@
* limitations under the License.
*/
import knexFactory from 'knex';
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { startMysqlContainer } from './startMysqlContainer';
import { startPostgresContainer } from './startPostgresContainer';
import { TestDatabases } from './TestDatabases';
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
jest.setTimeout(60_000);
describe('TestDatabases', () => {
const OLD_ENV = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...OLD_ENV };
});
afterAll(() => {
process.env = OLD_ENV;
});
describe('each create', () => {
const dbs = TestDatabases.create();
@@ -55,254 +38,4 @@ describe('TestDatabases', () => {
},
);
});
describe('each connect', () => {
const dbs = TestDatabases.create();
itIfDocker(
'obeys a provided connection string for postgres 16',
async () => {
const { host, port, user, password, stop } =
await startPostgresContainer('postgres:16');
try {
// Leave a mark
process.env.BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
const input = await dbs.init('POSTGRES_16');
await input.schema.createTable('a', table =>
table.string('x').primary(),
);
await input.insert({ x: 'y' }).into('a');
// Look for the mark
const database = input.client.config.connection.database;
const output = knexFactory({
client: 'pg',
connection: { host, port, user, password, database },
});
// eslint-disable-next-line jest/no-standalone-expect
await expect(output.select('x').from('a')).resolves.toEqual([
{ x: 'y' },
]);
} finally {
await stop();
}
},
);
itIfDocker(
'obeys a provided connection string for postgres 15',
async () => {
const { host, port, user, password, stop } =
await startPostgresContainer('postgres:15');
try {
// Leave a mark
process.env.BACKSTAGE_TEST_DATABASE_POSTGRES15_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
const input = await dbs.init('POSTGRES_15');
await input.schema.createTable('a', table =>
table.string('x').primary(),
);
await input.insert({ x: 'y' }).into('a');
// Look for the mark
const database = input.client.config.connection.database;
const output = knexFactory({
client: 'pg',
connection: { host, port, user, password, database },
});
// eslint-disable-next-line jest/no-standalone-expect
await expect(output.select('x').from('a')).resolves.toEqual([
{ x: 'y' },
]);
} finally {
await stop();
}
},
);
itIfDocker(
'obeys a provided connection string for postgres 14',
async () => {
const { host, port, user, password, stop } =
await startPostgresContainer('postgres:14');
try {
// Leave a mark
process.env.BACKSTAGE_TEST_DATABASE_POSTGRES14_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
const input = await dbs.init('POSTGRES_14');
await input.schema.createTable('a', table =>
table.string('x').primary(),
);
await input.insert({ x: 'y' }).into('a');
// Look for the mark
const database = input.client.config.connection.database;
const output = knexFactory({
client: 'pg',
connection: { host, port, user, password, database },
});
// eslint-disable-next-line jest/no-standalone-expect
await expect(output.select('x').from('a')).resolves.toEqual([
{ x: 'y' },
]);
} finally {
await stop();
}
},
);
itIfDocker(
'obeys a provided connection string for postgres 13',
async () => {
const { host, port, user, password, stop } =
await startPostgresContainer('postgres:13');
try {
// Leave a mark
process.env.BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
const input = await dbs.init('POSTGRES_13');
await input.schema.createTable('a', table =>
table.string('x').primary(),
);
await input.insert({ x: 'y' }).into('a');
// Look for the mark
const database = input.client.config.connection.database;
const output = knexFactory({
client: 'pg',
connection: { host, port, user, password, database },
});
// eslint-disable-next-line jest/no-standalone-expect
await expect(output.select('x').from('a')).resolves.toEqual([
{ x: 'y' },
]);
} finally {
await stop();
}
},
);
itIfDocker(
'obeys a provided connection string for postgres 12',
async () => {
const { host, port, user, password, stop } =
await startPostgresContainer('postgres:12');
try {
// Leave a mark
process.env.BACKSTAGE_TEST_DATABASE_POSTGRES12_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
const input = await dbs.init('POSTGRES_12');
await input.schema.createTable('a', table =>
table.string('x').primary(),
);
await input.insert({ x: 'y' }).into('a');
// Look for the mark
const database = input.client.config.connection.database;
const output = knexFactory({
client: 'pg',
connection: { host, port, user, password, database },
});
// eslint-disable-next-line jest/no-standalone-expect
await expect(output.select('x').from('a')).resolves.toEqual([
{ x: 'y' },
]);
} finally {
await stop();
}
},
);
itIfDocker(
'obeys a provided connection string for postgres 11',
async () => {
const { host, port, user, password, stop } =
await startPostgresContainer('postgres:11');
try {
// Leave a mark
process.env.BACKSTAGE_TEST_DATABASE_POSTGRES11_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
const input = await dbs.init('POSTGRES_11');
await input.schema.createTable('a', table =>
table.string('x').primary(),
);
await input.insert({ x: 'y' }).into('a');
// Look for the mark
const database = input.client.config.connection.database;
const output = knexFactory({
client: 'pg',
connection: { host, port, user, password, database },
});
// eslint-disable-next-line jest/no-standalone-expect
await expect(output.select('x').from('a')).resolves.toEqual([
{ x: 'y' },
]);
} finally {
await stop();
}
},
);
itIfDocker(
'obeys a provided connection string for postgres 9',
async () => {
const { host, port, user, password, stop } =
await startPostgresContainer('postgres:9');
try {
// Leave a mark
process.env.BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`;
const input = await dbs.init('POSTGRES_9');
await input.schema.createTable('a', table =>
table.string('x').primary(),
);
await input.insert({ x: 'y' }).into('a');
// Look for the mark
const database = input.client.config.connection.database;
const output = knexFactory({
client: 'pg',
connection: { host, port, user, password, database },
});
// eslint-disable-next-line jest/no-standalone-expect
await expect(output.select('x').from('a')).resolves.toEqual([
{ x: 'y' },
]);
} finally {
await stop();
}
},
);
itIfDocker('obeys a provided connection string for mysql 8', async () => {
const { host, port, user, password, stop } = await startMysqlContainer(
'mysql:8',
);
try {
// Leave a mark
process.env.BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING = `mysql://${user}:${password}@${host}:${port}/ignored`;
const input = await dbs.init('MYSQL_8');
await input.schema.createTable('a', table =>
table.string('x').primary(),
);
await input.insert({ x: 'y' }).into('a');
// Look for the mark
const database = input.client.config.connection.database;
const output = knexFactory({
client: 'mysql2',
connection: { host, port, user, password, database },
});
// eslint-disable-next-line jest/no-standalone-expect
await expect(output.select('x').from('a')).resolves.toEqual([
{ x: 'y' },
]);
} finally {
await stop();
}
});
});
});
@@ -14,27 +14,18 @@
* limitations under the License.
*/
import { DatabaseManager, dropDatabase } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { randomBytes } from 'crypto';
import { Knex } from 'knex';
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { startMysqlContainer } from './startMysqlContainer';
import { startPostgresContainer } from './startPostgresContainer';
import { MysqlEngine } from './mysql';
import { PostgresEngine } from './postgres';
import { SqliteEngine } from './sqlite';
import {
allDatabases,
Instance,
Engine,
TestDatabaseId,
TestDatabaseProperties,
allDatabases,
} from './types';
const LARGER_POOL_CONFIG = {
pool: {
min: 0,
max: 50,
},
};
/**
* Encapsulates the creation of ephemeral test database instances for use
* inside unit or integration tests.
@@ -42,7 +33,17 @@ const LARGER_POOL_CONFIG = {
* @public
*/
export class TestDatabases {
private readonly instanceById: Map<string, Instance>;
private readonly engineFactoryByDriver: Record<
string,
(properties: TestDatabaseProperties) => Promise<Engine>
> = {
pg: PostgresEngine.create,
mysql: MysqlEngine.create,
mysql2: MysqlEngine.create,
'better-sqlite3': SqliteEngine.create,
sqlite3: SqliteEngine.create,
};
private readonly engineByTestDatabaseId: Map<string, Engine>;
private readonly supportedIds: TestDatabaseId[];
private static defaultIds?: TestDatabaseId[];
@@ -114,7 +115,7 @@ export class TestDatabases {
}
private constructor(supportedIds: TestDatabaseId[]) {
this.instanceById = new Map();
this.engineByTestDatabaseId = new Map();
this.supportedIds = supportedIds;
}
@@ -148,185 +149,29 @@ export class TestDatabases {
);
}
let instance: Instance | undefined = this.instanceById.get(id);
// Ensure that a testcontainers instance is up for this ID
if (!instance) {
instance = await this.initAny(properties);
this.instanceById.set(id, instance);
}
// Ensure that a unique logical database is created in the instance
const databaseName = `db${randomBytes(16).toString('hex')}`;
const connection = await instance.databaseManager
.forPlugin(databaseName)
.getClient();
instance.connections.push(connection);
instance.databaseNames.push(databaseName);
return connection;
}
private async initAny(properties: TestDatabaseProperties): Promise<Instance> {
// Use the connection string if provided
if (properties.driver === 'pg' || properties.driver === 'mysql2') {
const envVarName = properties.connectionStringEnvironmentVariableName;
if (envVarName) {
const connectionString = process.env[envVarName];
if (connectionString) {
const config = new ConfigReader({
backend: {
database: {
knexConfig: properties.driver.includes('sqlite')
? {}
: LARGER_POOL_CONFIG,
client: properties.driver,
connection: connectionString,
},
},
});
const databaseManager = DatabaseManager.fromConfig(config);
const databaseNames: Array<string> = [];
return {
dropDatabases: async () => {
await dropDatabase(
config.getConfig('backend.database'),
...databaseNames.map(
databaseName => `backstage_plugin_${databaseName}`,
),
);
},
databaseManager,
databaseNames,
connections: [],
};
}
}
}
// Otherwise start a container for the purpose
switch (properties.driver) {
case 'pg':
return this.initPostgres(properties);
case 'mysql2':
return this.initMysql(properties);
case 'better-sqlite3':
case 'sqlite3':
return this.initSqlite(properties);
default:
let engine = this.engineByTestDatabaseId.get(id);
if (!engine) {
const factory = this.engineFactoryByDriver[properties.driver];
if (!factory) {
throw new Error(`Unknown database driver ${properties.driver}`);
}
engine = await factory(properties);
this.engineByTestDatabaseId.set(id, engine);
}
}
private async initPostgres(
properties: TestDatabaseProperties,
): Promise<Instance> {
const { host, port, user, password, stop } = await startPostgresContainer(
properties.dockerImageName!,
);
const databaseManager = DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
knexConfig: LARGER_POOL_CONFIG,
client: 'pg',
connection: { host, port, user, password },
},
},
}),
);
return {
stopContainer: stop,
databaseManager,
databaseNames: [],
connections: [],
};
}
private async initMysql(
properties: TestDatabaseProperties,
): Promise<Instance> {
const { host, port, user, password, stop } = await startMysqlContainer(
properties.dockerImageName!,
);
const databaseManager = DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
knexConfig: LARGER_POOL_CONFIG,
client: 'mysql2',
connection: { host, port, user, password },
},
},
}),
);
return {
stopContainer: stop,
databaseManager,
databaseNames: [],
connections: [],
};
}
private async initSqlite(
properties: TestDatabaseProperties,
): Promise<Instance> {
const databaseManager = DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: properties.driver,
connection: ':memory:',
},
},
}),
);
return {
databaseManager,
databaseNames: [],
connections: [],
};
return await engine.createDatabaseInstance();
}
private async shutdown() {
const instances = [...this.instanceById.values()];
this.instanceById.clear();
const engines = [...this.engineByTestDatabaseId.values()];
this.engineByTestDatabaseId.clear();
for (const {
stopContainer,
dropDatabases,
connections,
databaseManager,
} of instances) {
for (const connection of connections) {
try {
await connection.destroy();
} catch (error) {
console.warn(`TestDatabases: Failed to destroy connection`, {
connection,
error,
});
}
}
// If the database is not running in docker then drop the databases
for (const engine of engines) {
try {
await dropDatabases?.();
await engine.shutdown();
} catch (error) {
console.warn(`TestDatabases: Failed to drop databases`, {
error,
});
}
try {
await stopContainer?.();
} catch (error) {
console.warn(`TestDatabases: Failed to stop container`, {
databaseManager,
console.warn(`TestDatabases: Failed to shutdown engine`, {
engine,
error,
});
}
@@ -0,0 +1,113 @@
/*
* Copyright 2021 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 knexFactory, { Knex } from 'knex';
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { MysqlEngine, startMysqlContainer } from './mysql';
import { Engine, TestDatabaseId, allDatabases } from './types';
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
const ourDatabaseIds = Object.entries(allDatabases)
.filter(([, properties]) => properties.driver.includes('mysql'))
.map(([id]) => id as TestDatabaseId);
jest.setTimeout(60_000);
describe('startMysqlContainer', () => {
itIfDocker(
'successfully launches the container and can stop it without problems',
async () => {
const { connection, stopContainer } = await startMysqlContainer(
'mysql:8',
);
const db = knexFactory({ client: 'mysql2', connection });
try {
const result = await db.select(db.raw('version() AS version'));
// eslint-disable-next-line jest/no-standalone-expect
expect(result[0]?.version).toContain('8.');
} finally {
await db.destroy();
await stopContainer();
}
},
);
});
describe('MysqlEngine', () => {
const OLD_ENV = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...OLD_ENV };
});
afterAll(() => {
process.env = OLD_ENV;
});
itIfDocker.each(ourDatabaseIds)(
'uses given connection string, %p',
async testDatabaseId => {
const properties = allDatabases[testDatabaseId];
const { connection } = await startMysqlContainer(
properties.dockerImageName!,
);
const outerKnex = knexFactory({ client: properties.driver, connection });
const databases = await outerKnex
.raw('SHOW DATABASES')
.then(rows => rows[0].length); // account for meta databases, if any
let knex: Knex | undefined;
let engine: Engine | undefined;
try {
process.env[
properties.connectionStringEnvironmentVariableName!
] = `mysql://${connection.user}:${connection.password}@${connection.host}:${connection.port}/ignored`;
engine = await MysqlEngine.create(properties);
knex = await engine.createDatabaseInstance();
// eslint-disable-next-line jest/no-standalone-expect
await expect(
outerKnex.raw('SHOW DATABASES').then(rows => rows[0].length),
).resolves.toBe(databases + 1);
} finally {
await outerKnex.destroy();
await knex?.destroy();
await engine?.shutdown();
}
},
);
itIfDocker.each(ourDatabaseIds)(
'creates docker containers, %p',
async testDatabaseId => {
const properties = allDatabases[testDatabaseId];
delete process.env[properties.connectionStringEnvironmentVariableName!];
const engine = await MysqlEngine.create(properties);
try {
const knex = await engine.createDatabaseInstance();
// eslint-disable-next-line jest/no-standalone-expect
await expect(
knex.select(knex.raw('version() as version')),
).resolves.toEqual([{ version: expect.any(String) }]);
} finally {
await engine.shutdown();
}
},
);
});
@@ -0,0 +1,242 @@
/*
* Copyright 2021 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 { stringifyError } from '@backstage/errors';
import { randomBytes } from 'crypto';
import knexFactory, { Knex } from 'knex';
import { v4 as uuid } from 'uuid';
import yn from 'yn';
import { Engine, LARGER_POOL_CONFIG, TestDatabaseProperties } from './types';
async function waitForMysqlReady(
connection: Knex.MySqlConnectionConfig,
): Promise<void> {
const startTime = Date.now();
let lastError: Error | undefined;
let attempts = 0;
for (;;) {
attempts += 1;
let knex: Knex | undefined;
try {
knex = knexFactory({
client: 'mysql2',
connection: {
// make a copy because the driver mutates this
...connection,
},
});
const result = await knex.select(knex.raw('version() AS version'));
if (Array.isArray(result) && result[0]?.version) {
return;
}
} catch (e) {
lastError = e;
} finally {
await knex?.destroy();
}
if (Date.now() - startTime > 30_000) {
throw new Error(
`Timed out waiting for the database to be ready for connections, ${attempts} attempts, ${
lastError
? `last error was ${stringifyError(lastError)}`
: '(no errors thrown)'
}`,
);
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
export async function startMysqlContainer(image: string): Promise<{
connection: Knex.MySqlConnectionConfig;
stopContainer: () => Promise<void>;
}> {
const user = 'root';
const password = uuid();
// Lazy-load to avoid side-effect of importing testcontainers
const { GenericContainer } = await import('testcontainers');
const container = await new GenericContainer(image)
.withExposedPorts(3306)
.withEnvironment({ MYSQL_ROOT_PASSWORD: password })
.withTmpFs({ '/var/lib/mysql': 'rw' })
.start();
const host = container.getHost();
const port = container.getMappedPort(3306);
const connection = { host, port, user, password };
const stopContainer = async () => {
await container.stop({ timeout: 10_000 });
};
await waitForMysqlReady(connection);
return { connection, stopContainer };
}
export function parseMysqlConnectionString(
connectionString: string,
): Knex.MySqlConnectionConfig {
try {
const {
protocol,
username,
password,
port,
hostname,
pathname,
searchParams,
} = new URL(connectionString);
if (protocol !== 'mysql:') {
throw new Error(`Unknown protocol ${protocol}`);
} else if (!username || !password) {
throw new Error(`Missing username/password`);
} else if (!pathname.match(/^\/[^/]+$/)) {
throw new Error(`Expected single path segment`);
}
const result: Knex.MySqlConnectionConfig = {
user: username,
password,
host: hostname,
port: Number(port || 3306),
database: decodeURIComponent(pathname.substring(1)),
};
const ssl = searchParams.get('ssl');
if (ssl) {
result.ssl = ssl;
}
const debug = searchParams.get('debug');
if (debug) {
result.debug = yn(debug);
}
return result;
} catch (e) {
throw new Error(`Error while parsing MySQL connection string, ${e}`, e);
}
}
export class MysqlEngine implements Engine {
static async create(
properties: TestDatabaseProperties,
): Promise<MysqlEngine> {
const { connectionStringEnvironmentVariableName, dockerImageName } =
properties;
if (connectionStringEnvironmentVariableName) {
const connectionString =
process.env[connectionStringEnvironmentVariableName];
if (connectionString) {
const connection = parseMysqlConnectionString(connectionString);
return new MysqlEngine(
properties,
connection as Knex.MySqlConnectionConfig,
);
}
}
if (dockerImageName) {
const { connection, stopContainer } = await startMysqlContainer(
dockerImageName,
);
return new MysqlEngine(properties, connection, stopContainer);
}
throw new Error(`Test databasee for ${properties.name} not configured`);
}
readonly #properties: TestDatabaseProperties;
readonly #connection: Knex.MySqlConnectionConfig;
readonly #knexInstances: Knex[];
readonly #databaseNames: string[];
readonly #stopContainer?: () => Promise<void>;
constructor(
properties: TestDatabaseProperties,
connection: Knex.MySqlConnectionConfig,
stopContainer?: () => Promise<void>,
) {
this.#properties = properties;
this.#connection = connection;
this.#knexInstances = [];
this.#databaseNames = [];
this.#stopContainer = stopContainer;
}
async createDatabaseInstance(): Promise<Knex> {
const adminConnection = this.#connectAdmin();
try {
const databaseName = `db${randomBytes(16).toString('hex')}`;
await adminConnection.raw('CREATE DATABASE ??', [databaseName]);
this.#databaseNames.push(databaseName);
const knexInstance = knexFactory({
client: this.#properties.driver,
connection: {
...this.#connection,
database: databaseName,
},
...LARGER_POOL_CONFIG,
});
this.#knexInstances.push(knexInstance);
return knexInstance;
} finally {
await adminConnection.destroy();
}
}
async shutdown(): Promise<void> {
for (const instance of this.#knexInstances) {
await instance.destroy();
}
const adminConnection = this.#connectAdmin();
try {
for (const databaseName of this.#databaseNames) {
await adminConnection.raw('DROP DATABASE ??', [databaseName]);
}
} finally {
await adminConnection.destroy();
}
await this.#stopContainer?.();
}
#connectAdmin(): Knex {
const connection = {
...this.#connection,
database: null as unknown as string,
};
return knexFactory({
client: this.#properties.driver,
connection,
pool: {
acquireTimeoutMillis: 10000,
},
});
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2021 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 knexFactory, { Knex } from 'knex';
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { startPostgresContainer, PostgresEngine } from './postgres';
import { Engine, TestDatabaseId, allDatabases } from './types';
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
const ourDatabaseIds = Object.entries(allDatabases)
.filter(([, properties]) => properties.driver === 'pg')
.map(([id]) => id as TestDatabaseId);
jest.setTimeout(60_000);
describe('startPostgresContainer', () => {
itIfDocker(
'successfully launches the container and can stop it without problems',
async () => {
const { connection, stopContainer } = await startPostgresContainer(
'postgres:13',
);
const db = knexFactory({ client: 'pg', connection });
try {
const result = await db.select(db.raw('version()'));
// eslint-disable-next-line jest/no-standalone-expect
expect(result[0]?.version).toContain('PostgreSQL');
} finally {
await db.destroy();
await stopContainer();
}
},
);
});
describe('PostgresEngine', () => {
const OLD_ENV = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...OLD_ENV };
});
afterAll(() => {
process.env = OLD_ENV;
});
itIfDocker.each(ourDatabaseIds)(
'uses given connection string, %p',
async testDatabaseId => {
const properties = allDatabases[testDatabaseId];
const { connection } = await startPostgresContainer(
properties.dockerImageName!,
);
const outerKnex = knexFactory({ client: properties.driver, connection });
const databases = await outerKnex
.from('pg_database')
.then(rows => rows.length); // account for postgres, template0 etc
let knex: Knex | undefined;
let engine: Engine | undefined;
try {
process.env[
properties.connectionStringEnvironmentVariableName!
] = `postgres://${connection.user}:${connection.password}@${connection.host}:${connection.port}`;
engine = await PostgresEngine.create(properties);
knex = await engine.createDatabaseInstance();
// eslint-disable-next-line jest/no-standalone-expect
await expect(outerKnex.from('pg_database')).resolves.toHaveLength(
databases + 1,
);
} finally {
await outerKnex.destroy();
await knex?.destroy();
await engine?.shutdown();
}
},
);
itIfDocker.each(ourDatabaseIds)(
'creates docker containers, %p',
async testDatabaseId => {
const properties = allDatabases[testDatabaseId];
delete process.env[properties.connectionStringEnvironmentVariableName!];
const engine = await PostgresEngine.create(properties);
try {
const knex = await engine.createDatabaseInstance();
// eslint-disable-next-line jest/no-standalone-expect
await expect(knex.select(knex.raw('version()'))).resolves.toEqual([
{ version: expect.stringContaining('PostgreSQL') },
]);
} finally {
await engine.shutdown();
}
},
);
});
@@ -0,0 +1,195 @@
/*
* Copyright 2021 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 { stringifyError } from '@backstage/errors';
import { randomBytes } from 'crypto';
import knexFactory, { Knex } from 'knex';
import { parse as parsePgConnectionString } from 'pg-connection-string';
import { v4 as uuid } from 'uuid';
import { Engine, LARGER_POOL_CONFIG, TestDatabaseProperties } from './types';
async function waitForPostgresReady(
connection: Knex.PgConnectionConfig,
): Promise<void> {
const startTime = Date.now();
let lastError: Error | undefined;
let attempts = 0;
for (;;) {
attempts += 1;
let knex: Knex | undefined;
try {
knex = knexFactory({
client: 'pg',
connection: {
// make a copy because the driver mutates this
...connection,
},
});
const result = await knex.select(knex.raw('version()'));
if (Array.isArray(result) && result[0]?.version) {
return;
}
} catch (e) {
lastError = e;
} finally {
await knex?.destroy();
}
if (Date.now() - startTime > 30_000) {
throw new Error(
`Timed out waiting for the database to be ready for connections, ${attempts} attempts, ${
lastError
? `last error was ${stringifyError(lastError)}`
: '(no errors thrown)'
}`,
);
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
export async function startPostgresContainer(image: string): Promise<{
connection: Knex.PgConnectionConfig;
stopContainer: () => Promise<void>;
}> {
const user = 'postgres';
const password = uuid();
// Lazy-load to avoid side-effect of importing testcontainers
const { GenericContainer } = await import('testcontainers');
const container = await new GenericContainer(image)
.withExposedPorts(5432)
.withEnvironment({ POSTGRES_PASSWORD: password })
.withTmpFs({ '/var/lib/postgresql/data': 'rw' })
.start();
const host = container.getHost();
const port = container.getMappedPort(5432);
const connection = { host, port, user, password };
const stopContainer = async () => {
await container.stop({ timeout: 10_000 });
};
await waitForPostgresReady(connection);
return { connection, stopContainer };
}
export class PostgresEngine implements Engine {
static async create(
properties: TestDatabaseProperties,
): Promise<PostgresEngine> {
const { connectionStringEnvironmentVariableName, dockerImageName } =
properties;
if (connectionStringEnvironmentVariableName) {
const connectionString =
process.env[connectionStringEnvironmentVariableName];
if (connectionString) {
const connection = parsePgConnectionString(connectionString);
return new PostgresEngine(
properties,
connection as Knex.PgConnectionConfig,
);
}
}
if (dockerImageName) {
const { connection, stopContainer } = await startPostgresContainer(
dockerImageName,
);
return new PostgresEngine(properties, connection, stopContainer);
}
throw new Error(`Test databasee for ${properties.name} not configured`);
}
readonly #properties: TestDatabaseProperties;
readonly #connection: Knex.PgConnectionConfig;
readonly #knexInstances: Knex[];
readonly #databaseNames: string[];
readonly #stopContainer?: () => Promise<void>;
constructor(
properties: TestDatabaseProperties,
connection: Knex.PgConnectionConfig,
stopContainer?: () => Promise<void>,
) {
this.#properties = properties;
this.#connection = connection;
this.#knexInstances = [];
this.#databaseNames = [];
this.#stopContainer = stopContainer;
}
async createDatabaseInstance(): Promise<Knex> {
const adminConnection = this.#connectAdmin();
try {
const databaseName = `db${randomBytes(16).toString('hex')}`;
await adminConnection.raw('CREATE DATABASE ??', [databaseName]);
this.#databaseNames.push(databaseName);
const knexInstance = knexFactory({
client: this.#properties.driver,
connection: {
...this.#connection,
database: databaseName,
},
...LARGER_POOL_CONFIG,
});
this.#knexInstances.push(knexInstance);
return knexInstance;
} finally {
await adminConnection.destroy();
}
}
async shutdown(): Promise<void> {
for (const instance of this.#knexInstances) {
await instance.destroy();
}
const adminConnection = this.#connectAdmin();
try {
for (const databaseName of this.#databaseNames) {
await adminConnection.raw('DROP DATABASE ??', [databaseName]);
}
} finally {
await adminConnection.destroy();
}
await this.#stopContainer?.();
}
#connectAdmin(): Knex {
return knexFactory({
client: this.#properties.driver,
connection: {
...this.#connection,
database: 'postgres',
},
pool: {
acquireTimeoutMillis: 10000,
},
});
}
}
@@ -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 { SqliteEngine } from './sqlite';
import { TestDatabaseId, allDatabases } from './types';
const ourDatabaseIds = Object.entries(allDatabases)
.filter(([, properties]) => properties.driver.includes('sqlite'))
.map(([id]) => id as TestDatabaseId);
describe('SqliteEngine', () => {
it.each(ourDatabaseIds)(
'should create a database instance, %p',
async testDatabaseId => {
for (let i = 0; i < 100; ++i) {
const properties = allDatabases[testDatabaseId];
const engine = await SqliteEngine.create(properties);
const instance1 = await engine.createDatabaseInstance();
const instance2 = await engine.createDatabaseInstance();
expect(instance1).toBeDefined();
expect(instance2).toBeDefined();
expect(instance1).not.toEqual(instance2);
await instance1.schema.createTable('t', table => {
table.string('value');
});
await instance2.schema.createTable('t', table => {
table.string('value');
});
await instance1('t').insert({ value: 'value1' });
await instance2('t').insert({ value: 'value2' });
await expect(instance1('t')).resolves.toEqual([{ value: 'value1' }]);
await expect(instance2('t')).resolves.toEqual([{ value: 'value2' }]);
await engine.shutdown();
}
},
);
});
@@ -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 knexFactory, { Knex } from 'knex';
import { Engine, TestDatabaseProperties } from './types';
export class SqliteEngine implements Engine {
static async create(
properties: TestDatabaseProperties,
): Promise<SqliteEngine> {
return new SqliteEngine(properties);
}
readonly #properties: TestDatabaseProperties;
readonly #instances: Knex[];
constructor(properties: TestDatabaseProperties) {
this.#properties = properties;
this.#instances = [];
}
async createDatabaseInstance(): Promise<Knex> {
const instance = knexFactory({
client: this.#properties.driver,
connection: ':memory:',
useNullAsDefault: true,
});
instance.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
this.#instances.push(instance);
return instance;
}
async shutdown(): Promise<void> {
for (const instance of this.#instances) {
await instance.destroy();
}
}
}
@@ -1,38 +0,0 @@
/*
* Copyright 2021 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 createConnection from 'knex';
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { startMysqlContainer } from './startMysqlContainer';
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
jest.setTimeout(60_000);
describe('startMysqlContainer', () => {
itIfDocker('successfully launches the container', async () => {
const { stop, ...connection } = await startMysqlContainer('mysql:8');
const db = createConnection({ client: 'mysql2', connection });
try {
const result = await db.select(db.raw('version() AS version'));
// eslint-disable-next-line jest/no-standalone-expect
expect(result[0]?.version).toContain('8.');
} finally {
await db.destroy();
await stop();
}
});
});
@@ -1,70 +0,0 @@
/*
* Copyright 2021 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 createConnection, { Knex } from 'knex';
import { v4 as uuid } from 'uuid';
async function waitForMysqlReady(
connection: Knex.MySqlConnectionConfig,
): Promise<void> {
const startTime = Date.now();
const db = createConnection({ client: 'mysql2', connection });
try {
for (;;) {
try {
const result = await db.select(db.raw('version() AS version'));
if (result[0]?.version) {
return;
}
} catch (e) {
if (Date.now() - startTime > 30_000) {
throw new Error(
`Timed out waiting for the database to be ready for connections, ${e}`,
);
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
} finally {
db.destroy();
}
}
export async function startMysqlContainer(image: string) {
const user = 'root';
const password = uuid();
// Lazy-load to avoid side-effect of importing testcontainers
const { GenericContainer } = await import('testcontainers');
const container = await new GenericContainer(image)
.withExposedPorts(3306)
.withEnvironment({ MYSQL_ROOT_PASSWORD: password })
.withTmpFs({ '/var/lib/mysql': 'rw' })
.start();
const host = container.getHost();
const port = container.getMappedPort(3306);
const stop = async () => {
await container.stop({ timeout: 10_000 });
};
await waitForMysqlReady({ host, port, user, password });
return { host, port, user, password, stop };
}
@@ -1,38 +0,0 @@
/*
* Copyright 2021 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 createConnection from 'knex';
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { startPostgresContainer } from './startPostgresContainer';
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
jest.setTimeout(60_000);
describe('startPostgresContainer', () => {
itIfDocker('successfully launches the container', async () => {
const { stop, ...connection } = await startPostgresContainer('postgres:13');
const db = createConnection({ client: 'pg', connection });
try {
const result = await db.select(db.raw('version()'));
// eslint-disable-next-line jest/no-standalone-expect
expect(result[0]?.version).toContain('PostgreSQL');
} finally {
await db.destroy();
await stop();
}
});
});
@@ -1,70 +0,0 @@
/*
* Copyright 2021 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 createConnection, { Knex } from 'knex';
import { v4 as uuid } from 'uuid';
async function waitForPostgresReady(
connection: Knex.PgConnectionConfig,
): Promise<void> {
const startTime = Date.now();
const db = createConnection({ client: 'pg', connection });
try {
for (;;) {
try {
const result = await db.select(db.raw('version()'));
if (Array.isArray(result) && result[0]?.version) {
return;
}
} catch (e) {
if (Date.now() - startTime > 30_000) {
throw new Error(
`Timed out waiting for the database to be ready for connections, ${e}`,
);
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
} finally {
db.destroy();
}
}
export async function startPostgresContainer(image: string) {
const user = 'postgres';
const password = uuid();
// Lazy-load to avoid side-effect of importing testcontainers
const { GenericContainer } = await import('testcontainers');
const container = await new GenericContainer(image)
.withExposedPorts(5432)
.withEnvironment({ POSTGRES_PASSWORD: password })
.withTmpFs({ '/var/lib/postgresql/data': 'rw' })
.start();
const host = container.getHost();
const port = container.getMappedPort(5432);
const stop = async () => {
await container.stop({ timeout: 10_000 });
};
await waitForPostgresReady({ host, port, user, password });
return { host, port, user, password, stop };
}
@@ -14,10 +14,14 @@
* limitations under the License.
*/
import { DatabaseManager } from '@backstage/backend-common';
import { Knex } from 'knex';
import { getDockerImageForName } from '../util/getDockerImageForName';
export interface Engine {
createDatabaseInstance(): Promise<Knex>;
shutdown(): Promise<void>;
}
/**
* The possible databases to test against.
*
@@ -41,14 +45,6 @@ export type TestDatabaseProperties = {
connectionStringEnvironmentVariableName?: string;
};
export type Instance = {
stopContainer?: () => Promise<void>;
dropDatabases?: () => Promise<void>;
databaseManager: DatabaseManager;
connections: Array<Knex>;
databaseNames: Array<string>;
};
export const allDatabases: Record<TestDatabaseId, TestDatabaseProperties> =
Object.freeze({
POSTGRES_16: {
@@ -112,3 +108,10 @@ export const allDatabases: Record<TestDatabaseId, TestDatabaseProperties> =
driver: 'better-sqlite3',
},
});
export const LARGER_POOL_CONFIG = {
pool: {
min: 0,
max: 50,
},
};
+2 -2
View File
@@ -25,7 +25,7 @@ import { PermissionsServiceRequestOptions } from '@backstage/backend-plugin-api'
import { PolicyDecision } from '@backstage/plugin-permission-common';
import { QueryPermissionRequest } from '@backstage/plugin-permission-common';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { TokenManager } from '@backstage/backend-common';
import { TokenManagerService } from '@backstage/backend-plugin-api';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
@@ -289,7 +289,7 @@ export class ServerPermissionClient implements PermissionsService {
config: Config,
options: {
discovery: DiscoveryService;
tokenManager: TokenManager;
tokenManager: TokenManagerService;
auth?: AuthService;
},
): ServerPermissionClient;
@@ -28,24 +28,13 @@ import {
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import {
PluginEndpointDiscovery,
ServerTokenManager,
} from '@backstage/backend-common';
import { setupServer } from 'msw/node';
import { RestContext, rest } from 'msw';
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discovery: PluginEndpointDiscovery = {
async getBaseUrl() {
return mockBaseUrl;
},
async getExternalBaseUrl() {
return mockBaseUrl;
},
};
const testBasicPermission = createPermission({
name: 'test.permission',
attributes: {
@@ -63,21 +52,14 @@ const config = new ConfigReader({
permission: { enabled: true },
backend: { auth: { keys: [{ secret: 'a-secret-key' }] } },
});
const logger = mockServices.logger.mock();
describe('ServerPermissionClient', () => {
setupRequestMockHandlers(server);
it('should error if permissions are enabled but a no-op token manager is configured', async () => {
expect(() =>
ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager: ServerTokenManager.noop(),
}),
).toThrow(
'Service-to-service authentication must be configured before enabling permissions. Read more here https://backstage.io/docs/auth/service-to-service-auth',
);
});
const discovery = mockServices.discovery.mock();
discovery.getBaseUrl.mockResolvedValue(mockBaseUrl);
discovery.getExternalBaseUrl.mockResolvedValue(mockBaseUrl);
describe('authorize', () => {
let mockAuthorizeHandler: jest.Mock;
@@ -100,7 +82,7 @@ describe('ServerPermissionClient', () => {
it('should bypass the permission backend if permissions are disabled', async () => {
const client = ServerPermissionClient.fromConfig(new ConfigReader({}), {
discovery,
tokenManager: ServerTokenManager.noop(),
tokenManager: mockServices.tokenManager.mock(),
});
await client.authorize([
@@ -113,28 +95,28 @@ describe('ServerPermissionClient', () => {
});
it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
tokenManager: mockServices.tokenManager.mock(),
auth: mockServices.auth(),
});
await client.authorize([{ permission: testBasicPermission }], {
token: (await tokenManager.getToken()).token,
token: mockCredentials.service.token(),
});
expect(mockAuthorizeHandler).not.toHaveBeenCalled();
});
it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
tokenManager: mockServices.tokenManager.mock(),
auth: mockServices.auth(),
});
await client.authorize([{ permission: testBasicPermission }], {
token: 'a-user-token',
token: mockCredentials.user.token(),
});
expect(mockAuthorizeHandler).toHaveBeenCalled();
@@ -162,7 +144,7 @@ describe('ServerPermissionClient', () => {
it('should bypass the permission backend if permissions are disabled', async () => {
const client = ServerPermissionClient.fromConfig(new ConfigReader({}), {
discovery,
tokenManager: ServerTokenManager.noop(),
tokenManager: mockServices.tokenManager.mock(),
});
await client.authorizeConditional([
@@ -173,16 +155,15 @@ describe('ServerPermissionClient', () => {
});
it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
tokenManager: mockServices.tokenManager.mock(),
});
await client.authorizeConditional(
[{ permission: testResourcePermission }],
{
token: (await tokenManager.getToken()).token,
token: mockCredentials.service.token(),
},
);
@@ -190,16 +171,17 @@ describe('ServerPermissionClient', () => {
});
it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const auth = mockServices.auth();
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
tokenManager: mockServices.tokenManager.mock(),
auth,
});
await client.authorizeConditional(
[{ permission: testResourcePermission }],
{
token: 'a-user-token',
token: mockCredentials.user.token(),
},
);
@@ -229,7 +211,7 @@ describe('ServerPermissionClient', () => {
it('should bypass the permission backend if permissions are disabled', async () => {
const client = ServerPermissionClient.fromConfig(new ConfigReader({}), {
discovery,
tokenManager: ServerTokenManager.noop(),
tokenManager: mockServices.tokenManager.mock(),
auth: mockServices.auth(),
});
@@ -246,10 +228,9 @@ describe('ServerPermissionClient', () => {
});
it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
tokenManager: mockServices.tokenManager.mock(),
auth: mockServices.auth(),
});
@@ -261,10 +242,9 @@ describe('ServerPermissionClient', () => {
});
it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
tokenManager: mockServices.tokenManager.mock(),
auth: mockServices.auth(),
});
@@ -305,7 +285,7 @@ describe('ServerPermissionClient', () => {
it('should bypass the permission backend if permissions are disabled', async () => {
const client = ServerPermissionClient.fromConfig(new ConfigReader({}), {
discovery,
tokenManager: ServerTokenManager.noop(),
tokenManager: mockServices.tokenManager.mock(),
auth: mockServices.auth(),
});
@@ -320,10 +300,9 @@ describe('ServerPermissionClient', () => {
});
it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
tokenManager: mockServices.tokenManager.mock(),
auth: mockServices.auth(),
});
@@ -338,10 +317,9 @@ describe('ServerPermissionClient', () => {
});
it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
tokenManager: mockServices.tokenManager.mock(),
auth: mockServices.auth(),
});
@@ -14,10 +14,7 @@
* limitations under the License.
*/
import {
TokenManager,
createLegacyAuthAdapters,
} from '@backstage/backend-common';
import { createLegacyAuthAdapters } from '@backstage/backend-common';
import {
AuthService,
BackstageCredentials,
@@ -25,6 +22,7 @@ import {
DiscoveryService,
PermissionsService,
PermissionsServiceRequestOptions,
TokenManagerService,
} from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import {
@@ -53,7 +51,7 @@ export class ServerPermissionClient implements PermissionsService {
config: Config,
options: {
discovery: DiscoveryService;
tokenManager: TokenManager;
tokenManager: TokenManagerService;
auth?: AuthService;
},
) {
@@ -18,7 +18,11 @@ import {
authServiceFactory,
httpAuthServiceFactory,
} from '@backstage/backend-app-api';
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import {
mockServices,
setupRequestMockHandlers,
startTestBackend,
} from '@backstage/backend-test-utils';
import { ResponseError } from '@backstage/errors';
import { JsonObject } from '@backstage/types';
import { HttpResponse, http, passthrough } from 'msw';
@@ -30,8 +34,7 @@ import fetch from 'node-fetch';
describe('credentials', () => {
const worker = setupServer();
beforeAll(() => worker.listen({ onUnhandledRequest: 'error' }));
afterEach(() => worker.resetHandlers());
setupRequestMockHandlers(worker);
it('handles all valid credentials settings', async () => {
const config = {
+5 -4
View File
@@ -3574,7 +3574,6 @@ __metadata:
resolution: "@backstage/backend-test-utils@workspace:packages/backend-test-utils"
dependencies:
"@backstage/backend-app-api": "workspace:^"
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
@@ -3595,10 +3594,12 @@ __metadata:
msw: ^1.0.0
mysql2: ^3.0.0
pg: ^8.11.3
pg-connection-string: ^2.3.0
supertest: ^6.1.3
testcontainers: ^10.0.0
textextensions: ^5.16.0
uuid: ^9.0.0
yn: ^4.0.0
peerDependencies:
"@types/jest": "*"
languageName: unknown
@@ -33585,8 +33586,8 @@ __metadata:
linkType: hard
"mysql2@npm:^3.0.0":
version: 3.9.7
resolution: "mysql2@npm:3.9.7"
version: 3.10.0
resolution: "mysql2@npm:3.10.0"
dependencies:
denque: ^2.1.0
generate-function: ^2.3.1
@@ -33596,7 +33597,7 @@ __metadata:
named-placeholders: ^1.1.3
seq-queue: ^0.0.5
sqlstring: ^2.3.2
checksum: 535261d076f840f0966788b3f33a5ff7872e5da321240c2359be5c9e7ec19197ed5f6e01f0bc7beae06dd291d03eb2bde00f474461a578debcb85fcd98e347d3
checksum: 4306de21317a05fcd4bbf28679e6b61b5e3421f7b8e2813ce7be8a06a8f7f4e09067bbd77507de465aba59a5742d1c48a3ed1038e96fb76935d24e1648d4b9c3
languageName: node
linkType: hard